one-code-extension 0.1.3 → 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 -202
- 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
package/extensions/mcp/index.ts
CHANGED
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import os from "node:os";
|
|
19
|
-
import type
|
|
19
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { MCP_TOOLS_CHANNEL } from "../lib/mcp-share.ts";
|
|
23
|
+
import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
|
|
23
24
|
import { ccToolRenderers } from "../lib/tui-render.ts";
|
|
24
25
|
import {
|
|
25
26
|
callTool,
|
|
@@ -27,15 +28,35 @@ import {
|
|
|
27
28
|
type Connection,
|
|
28
29
|
connect,
|
|
29
30
|
type FailedConnection,
|
|
31
|
+
isUnauthorized,
|
|
32
|
+
mcpFailuresReminder,
|
|
30
33
|
mcpInstructionsReminder,
|
|
31
34
|
readResource,
|
|
32
35
|
readResourceDir,
|
|
33
36
|
} from "./client.ts";
|
|
34
37
|
import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
35
38
|
import { join } from "node:path";
|
|
36
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
MCP_STATUS_CHANNEL,
|
|
41
|
+
MCP_STATUS_REQUEST_CHANNEL,
|
|
42
|
+
type McpServerStatus,
|
|
43
|
+
type McpStatusEvent,
|
|
44
|
+
} from "../lib/mcp-status.ts";
|
|
45
|
+
import { defaultDiscoverRoots, discoverPlugins } from "../lib/plugins.ts";
|
|
46
|
+
import { readDisabledMcpServers, setMcpServerDisabled } from "../lib/mcp-overrides.ts";
|
|
47
|
+
import { boundedDockHeight, safeThemeBold, safeThemePaint, truncateLine } from "../lib/tui-render.ts";
|
|
48
|
+
import { authenticate as runOAuthFlow, silentProvider } from "./oauth/flow.ts";
|
|
49
|
+
import { hasStoredTokens } from "./oauth/store.ts";
|
|
50
|
+
import { announcedFrom, applyMcpDelta, emptyAnnounced, mcpChangeNotices, mcpDelta, type McpSnapshot } from "./announce.ts";
|
|
51
|
+
import { decodeMcpKey } from "./panel/keys.ts";
|
|
52
|
+
import { type McpEntry, type McpEntryStatus } from "./panel/model.ts";
|
|
53
|
+
import { renderMcpPanel, type McpPaint } from "./panel/render.ts";
|
|
54
|
+
import { applyMcpKey, initialMcpState, type McpEffect } from "./panel/state.ts";
|
|
55
|
+
import { sessionAlive } from "../lib/session-lifecycle.ts";
|
|
37
56
|
import { loadServers, type McpServer } from "./config.ts";
|
|
57
|
+
import { approveMcpServers, persistApproval, projectRootOf } from "./trust.ts";
|
|
38
58
|
import {
|
|
59
|
+
capDescription,
|
|
39
60
|
describeContent,
|
|
40
61
|
describeResourceContents,
|
|
41
62
|
jsonSchemaToTypeBox,
|
|
@@ -44,6 +65,9 @@ import {
|
|
|
44
65
|
namespacedToolName,
|
|
45
66
|
} from "./schema.ts";
|
|
46
67
|
|
|
68
|
+
/** Anthropic's hard limit on a tool name; a longer one fails the whole request. */
|
|
69
|
+
const MAX_TOOL_NAME_LENGTH = 128;
|
|
70
|
+
|
|
47
71
|
/** Shared "server name not found" tool result for the resource read/list-dir tools. */
|
|
48
72
|
function unknownServerError(name: string) {
|
|
49
73
|
return {
|
|
@@ -55,36 +79,72 @@ function unknownServerError(name: string) {
|
|
|
55
79
|
|
|
56
80
|
export default function mcpExtension(pi: ExtensionAPI) {
|
|
57
81
|
const connections = new Map<string, Connection>();
|
|
58
|
-
|
|
82
|
+
let failures: FailedConnection[] = [];
|
|
59
83
|
const registered = new Set<string>();
|
|
60
84
|
let servers: McpServer[] = [];
|
|
85
|
+
// http servers that returned a 401 with no stored tokens — authNeeded via OAuth,
|
|
86
|
+
// distinct from a missing-env authNeeded (which Authenticate cannot fix).
|
|
87
|
+
const oauthNeeded = new Set<string>();
|
|
88
|
+
// Servers the user disabled (persisted in ~/.onecode) — discovered but not connected.
|
|
89
|
+
let disabledNames = new Set<string>();
|
|
90
|
+
// Project .mcp.json servers withheld this session (no consent yet, declined,
|
|
91
|
+
// or disabled via Claude Code's disabledMcpjsonServers) — listed, not connected.
|
|
92
|
+
const withheldNames = new Map<string, string>();
|
|
93
|
+
// Config file paths contributed by plugins, so the panel can group them.
|
|
94
|
+
let pluginConfigPaths = new Set<string>();
|
|
95
|
+
const home = os.homedir();
|
|
96
|
+
// Live tool definitions, shared with in-process subagents so they reach MCP
|
|
97
|
+
// through these same open connections instead of connecting their own.
|
|
98
|
+
const sharedTools: ToolDefinition[] = [];
|
|
99
|
+
// Message 1 carries the MCP instructions and failed-servers reminders. Once a
|
|
100
|
+
// request has gone out it is part of every later request's cached prefix, so
|
|
101
|
+
// those blocks are frozen and later changes ride one-shot notices (./announce.ts).
|
|
102
|
+
let requestSent = false;
|
|
103
|
+
let announced = emptyAnnounced();
|
|
104
|
+
pi.on("context", () => {
|
|
105
|
+
requestSent = true;
|
|
106
|
+
});
|
|
61
107
|
|
|
62
108
|
/**
|
|
63
109
|
* Where oversized results are persisted — the session dir, matching Claude
|
|
64
110
|
* Code's `<session-dir>/tool-results/<id>.txt`. A session-less run (`--no-session`)
|
|
65
111
|
* has no such dir, so a temp folder serves; the model gets the path either way.
|
|
66
112
|
*/
|
|
67
|
-
const resultsDir = (ctx: ExtensionContext | undefined): string =>
|
|
68
|
-
try {
|
|
69
|
-
const dir = ctx?.sessionManager.getSessionDir();
|
|
70
|
-
if (dir) return dir;
|
|
71
|
-
} catch {
|
|
72
|
-
// fall through to the temp folder
|
|
73
|
-
}
|
|
74
|
-
return join(os.tmpdir(), "one-code");
|
|
75
|
-
};
|
|
113
|
+
const resultsDir = (ctx: ExtensionContext | undefined): string => sessionResultsDir(ctx);
|
|
76
114
|
|
|
77
115
|
const registerToolsFor = (connection: Connection) => {
|
|
78
116
|
for (const tool of connection.tools) {
|
|
79
117
|
const name = namespacedToolName(connection.server.name, tool.name);
|
|
80
|
-
|
|
118
|
+
// Anthropic caps a tool name at 128 characters and rejects the whole
|
|
119
|
+
// request when one is longer — and a deferred definition rides every
|
|
120
|
+
// request from request 1, so one over-long name would fail every turn
|
|
121
|
+
// of the session with an opaque `tools.N.custom.name` error. Drop it
|
|
122
|
+
// here with a clear reason instead.
|
|
123
|
+
if (name.length > MAX_TOOL_NAME_LENGTH) {
|
|
124
|
+
failures.push({
|
|
125
|
+
server: connection.server,
|
|
126
|
+
error: `tool "${tool.name}" skipped: its namespaced name is ${name.length} characters, over the ${MAX_TOOL_NAME_LENGTH}-character limit the provider enforces`,
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (registered.has(name)) {
|
|
131
|
+
// Two tools sanitise to one name (`a-b` and `a_b`, or a server
|
|
132
|
+
// reconnecting under a stale registration). Skipping silently would
|
|
133
|
+
// make the second tool unreachable with no trace; record it where
|
|
134
|
+
// the connection summary reports it.
|
|
135
|
+
failures.push({
|
|
136
|
+
server: connection.server,
|
|
137
|
+
error: `tool "${tool.name}" skipped: its registered name "${name}" is already taken by another tool`,
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
81
141
|
registered.add(name);
|
|
82
142
|
|
|
83
|
-
|
|
143
|
+
const def: ToolDefinition = {
|
|
84
144
|
name,
|
|
85
145
|
label: `${connection.server.name}: ${tool.name}`,
|
|
86
146
|
...ccToolRenderers(`${connection.server.name}: ${tool.name}`),
|
|
87
|
-
description: tool.description ?? `MCP tool "${tool.name}" from server "${connection.server.name}".`,
|
|
147
|
+
description: capDescription(tool.description) ?? `MCP tool "${tool.name}" from server "${connection.server.name}".`,
|
|
88
148
|
parameters: jsonSchemaToTypeBox(tool.inputSchema),
|
|
89
149
|
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
90
150
|
const live = connections.get(connection.server.name);
|
|
@@ -97,7 +157,7 @@ export default function mcpExtension(pi: ExtensionAPI) {
|
|
|
97
157
|
}
|
|
98
158
|
try {
|
|
99
159
|
const result = await callTool(live, tool.name, (params ?? {}) as Record<string, unknown>);
|
|
100
|
-
const { text, images } = describeContent(result.content as McpContentBlock[] | undefined);
|
|
160
|
+
const { text, images } = describeContent(result.content as McpContentBlock[] | undefined, connection.server.name);
|
|
101
161
|
return {
|
|
102
162
|
content: [
|
|
103
163
|
{
|
|
@@ -117,7 +177,9 @@ export default function mcpExtension(pi: ExtensionAPI) {
|
|
|
117
177
|
};
|
|
118
178
|
}
|
|
119
179
|
},
|
|
120
|
-
}
|
|
180
|
+
};
|
|
181
|
+
pi.registerTool(def);
|
|
182
|
+
sharedTools.push(def);
|
|
121
183
|
|
|
122
184
|
// Deferred: the model discovers these through tool_search.
|
|
123
185
|
pi.events.emit(DEFER_CHANNEL, {
|
|
@@ -125,82 +187,195 @@ export default function mcpExtension(pi: ExtensionAPI) {
|
|
|
125
187
|
keywords: [connection.server.name, tool.name.replace(/[_-]/g, " "), "mcp"],
|
|
126
188
|
});
|
|
127
189
|
}
|
|
190
|
+
// Publish the live set so in-process subagents can share these connections.
|
|
191
|
+
if (sharedTools.length > 0) pi.events.emit(MCP_TOOLS_CHANNEL, { tools: [...sharedTools] });
|
|
128
192
|
};
|
|
129
193
|
|
|
130
194
|
/** Resolves when every configured server has connected or failed. */
|
|
131
195
|
let connecting: Promise<void> | undefined;
|
|
132
196
|
let connectSettled = false;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const connectAll = async (ctx: ExtensionContext) => {
|
|
136
|
-
servers = loadServers(ctx.cwd, os.homedir(), process.env, discoverPlugins(join(os.homedir(), ".claude")).mcpConfigs);
|
|
137
|
-
if (servers.length === 0) return;
|
|
197
|
+
// False after session_shutdown: every pi.* call throws from then on (lib/session-lifecycle.ts).
|
|
198
|
+
const alive = sessionAlive(pi);
|
|
138
199
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Connect one server, updating the live maps. Prior failure/authNeeded state
|
|
202
|
+
* for the server is cleared first, so this doubles as the reconnect path.
|
|
203
|
+
* - An http server with stored OAuth tokens connects with the silent
|
|
204
|
+
* provider (refreshing as needed); without tokens it connects with NO
|
|
205
|
+
* provider so a 401 throws instead of popping a browser at startup.
|
|
206
|
+
* - A 401 from an http server marks it authNeeded (OAuth); any other error
|
|
207
|
+
* is a hard failure.
|
|
208
|
+
*/
|
|
209
|
+
/**
|
|
210
|
+
* Record a freshly established connection as the live one for its server:
|
|
211
|
+
* clear stale failure/authNeeded state, register its tools (and resource
|
|
212
|
+
* tools), and surface any listTools warnings. The single "now connected"
|
|
213
|
+
* path — both connectOne and the OAuth flow route through it, so neither can
|
|
214
|
+
* drift (e.g. drop the warnings step).
|
|
215
|
+
*/
|
|
216
|
+
const adoptConnection = (server: McpServer, connection: Connection): void => {
|
|
217
|
+
failures = failures.filter((f) => f.server.name !== server.name);
|
|
218
|
+
oauthNeeded.delete(server.name);
|
|
219
|
+
connections.set(server.name, connection);
|
|
220
|
+
// A server that dies (or drops the HTTP session) mid-session must not keep
|
|
221
|
+
// reading "connected" with a stale tool count; its tools stay registered
|
|
222
|
+
// and answer "not connected" until Reconnect in /mcp.
|
|
223
|
+
connection.client.onclose = () => {
|
|
224
|
+
if (connection.closing || !alive()) return;
|
|
225
|
+
if (connections.get(server.name) !== connection) return;
|
|
226
|
+
connections.delete(server.name);
|
|
227
|
+
const tail = connection.stderrTail();
|
|
142
228
|
failures.push({
|
|
143
229
|
server,
|
|
144
|
-
error: `
|
|
230
|
+
error: `connection closed by the server${tail ? `\nserver stderr: ${tail.slice(-1_000)}` : ""}`,
|
|
145
231
|
});
|
|
232
|
+
emitInstructions();
|
|
233
|
+
};
|
|
234
|
+
registerToolsFor(connection);
|
|
235
|
+
for (const warning of connection.warnings) failures.push({ server, error: warning });
|
|
236
|
+
if (connection.resources.length > 0) registerResourceTools();
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const connectOne = async (server: McpServer): Promise<void> => {
|
|
240
|
+
failures = failures.filter((f) => f.server.name !== server.name);
|
|
241
|
+
oauthNeeded.delete(server.name);
|
|
242
|
+
const provider = server.kind === "http" && hasStoredTokens(server.name) ? silentProvider(server.name) : undefined;
|
|
243
|
+
try {
|
|
244
|
+
const connection = await connect(server, provider);
|
|
245
|
+
if (!alive()) {
|
|
246
|
+
await close(connection);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
adoptConnection(server, connection);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (!alive()) return;
|
|
252
|
+
if (server.kind === "http" && isUnauthorized(error)) oauthNeeded.add(server.name);
|
|
253
|
+
else failures.push({ server, error: (error as Error).message });
|
|
146
254
|
}
|
|
255
|
+
};
|
|
147
256
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Tell the MODEL about the servers, the way Claude Code does (findings §14):
|
|
259
|
+
* each connected server's own usage instructions (its initialize result), and
|
|
260
|
+
* which servers failed — otherwise it concludes the tools do not exist or the
|
|
261
|
+
* user has no access instead of reporting a connection failure the user can
|
|
262
|
+
* fix (review M4). Re-run on every connect, close, reconnect, authenticate,
|
|
263
|
+
* enable and disable. Before the first request the two first-prepend blocks on
|
|
264
|
+
* message 1 are (re)written; after it they are frozen and only the changes are
|
|
265
|
+
* announced (./announce.ts).
|
|
266
|
+
*/
|
|
267
|
+
const emitInstructions = () => {
|
|
268
|
+
const live = [...connections.values()];
|
|
269
|
+
const snapshot: McpSnapshot = {
|
|
270
|
+
connected: live.map((c) => ({ name: c.server.name, instructions: c.instructions })),
|
|
271
|
+
failed: failures.filter((f) => !connections.has(f.server.name)).map((f) => ({ name: f.server.name, error: f.error })),
|
|
272
|
+
};
|
|
159
273
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
274
|
+
if (!requestSent) {
|
|
275
|
+
// Nothing is cached yet: (re)write the standing message-1 blocks.
|
|
276
|
+
if (snapshot.failed.length === 0) {
|
|
277
|
+
pi.events.emit(REMINDER_CHANNEL, { scope: "every-turn", key: "mcp-failures", text: "", remove: true });
|
|
278
|
+
} else {
|
|
279
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
280
|
+
scope: "every-turn",
|
|
281
|
+
key: "mcp-failures",
|
|
282
|
+
text: mcpFailuresReminder(snapshot.failed),
|
|
283
|
+
placement: "first-prepend",
|
|
284
|
+
order: CONTEXT_ORDER.mcp + 1,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const instructions = mcpInstructionsReminder(live);
|
|
288
|
+
if (instructions) {
|
|
289
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
290
|
+
scope: "every-turn",
|
|
291
|
+
key: "mcp-instructions",
|
|
292
|
+
text: instructions,
|
|
293
|
+
placement: "first-prepend",
|
|
294
|
+
order: CONTEXT_ORDER.mcp,
|
|
295
|
+
});
|
|
296
|
+
} else {
|
|
297
|
+
// No server has instructions anymore (e.g. the last one was disabled or
|
|
298
|
+
// disconnected) — drop the stale every-turn reminder so the model stops
|
|
299
|
+
// being told to use tools that are gone.
|
|
300
|
+
pi.events.emit(REMINDER_CHANNEL, { scope: "every-turn", key: "mcp-instructions", text: "", remove: true });
|
|
301
|
+
}
|
|
302
|
+
announced = announcedFrom(snapshot);
|
|
164
303
|
return;
|
|
165
304
|
}
|
|
166
305
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
306
|
+
// Message 1 is frozen (rewriting it would re-cache the whole conversation):
|
|
307
|
+
// tell the model only what changed, as one-shots where it reads next.
|
|
308
|
+
const delta = mcpDelta(announced, snapshot);
|
|
309
|
+
for (const text of mcpChangeNotices(delta)) pi.events.emit(REMINDER_CHANNEL, { text });
|
|
310
|
+
applyMcpDelta(announced, delta);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/** Config files that exist but failed to parse (review M11) — shown at startup and in /mcp. */
|
|
314
|
+
let configErrors: string[] = [];
|
|
315
|
+
|
|
316
|
+
const connectAll = async (ctx: ExtensionContext) => {
|
|
317
|
+
const plugins = discoverPlugins(defaultDiscoverRoots(getAgentDir(), ctx.cwd));
|
|
318
|
+
pluginConfigPaths = new Set(plugins.mcpConfigs);
|
|
319
|
+
configErrors = [];
|
|
320
|
+
servers = loadServers(ctx.cwd, home, process.env, [...pluginConfigPaths], {
|
|
321
|
+
pluginNames: plugins.mcpConfigPlugins,
|
|
322
|
+
onError: (path, message) => configErrors.push(`${path}: ${message}`),
|
|
323
|
+
});
|
|
324
|
+
if (configErrors.length > 0) {
|
|
325
|
+
const text = `MCP config could not be parsed (ignored): ${configErrors.join("; ")}`;
|
|
326
|
+
if (ctx.hasUI) ctx.ui.notify(text, "warning");
|
|
327
|
+
else process.stderr.write(`${text}\n`);
|
|
177
328
|
}
|
|
329
|
+
disabledNames = readDisabledMcpServers(ctx.cwd, home);
|
|
330
|
+
if (servers.length === 0) return;
|
|
178
331
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
332
|
+
// Disabled servers are listed but never connected; a server with an unset
|
|
333
|
+
// credential env var stays "needs authentication" and is not attempted
|
|
334
|
+
// (connecting with an empty token fails confusingly). A project .mcp.json
|
|
335
|
+
// server needs the user's consent first (trust.ts — a cloned repo must not
|
|
336
|
+
// run commands at startup). Everything else connects.
|
|
337
|
+
const candidates = servers.filter((server) => !disabledNames.has(server.name) && !server.missingEnv?.length);
|
|
338
|
+
const consent = await approveMcpServers(candidates, pluginConfigPaths, ctx.cwd, home, {
|
|
339
|
+
hasUI: ctx.hasUI,
|
|
340
|
+
select: (title, options) => ctx.ui.select(title, options),
|
|
341
|
+
notify: (message) => {
|
|
342
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
343
|
+
else process.stderr.write(`${message}\n`);
|
|
344
|
+
},
|
|
345
|
+
disable: (server) => {
|
|
346
|
+
setMcpServerDisabled(server.name, true, "project", ctx.cwd, home);
|
|
347
|
+
disabledNames.add(server.name);
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
if (!alive()) return;
|
|
351
|
+
withheldNames.clear();
|
|
352
|
+
for (const { server, reason } of consent.withheld) {
|
|
353
|
+
withheldNames.set(
|
|
354
|
+
server.name,
|
|
355
|
+
reason === "disabled-by-claude-settings"
|
|
356
|
+
? "disabled by disabledMcpjsonServers in Claude Code settings"
|
|
357
|
+
: reason === "declined"
|
|
358
|
+
? "not approved this session (project .mcp.json)"
|
|
359
|
+
: "not approved (project .mcp.json; approve in an interactive session)",
|
|
183
360
|
);
|
|
184
361
|
}
|
|
362
|
+
await Promise.all(consent.approved.map(connectOne));
|
|
185
363
|
|
|
186
|
-
|
|
187
|
-
if (resourceCount > 0) {
|
|
188
|
-
registerResourceTools();
|
|
189
|
-
}
|
|
364
|
+
if (!alive()) return;
|
|
190
365
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
order: CONTEXT_ORDER.mcp,
|
|
202
|
-
});
|
|
366
|
+
if (failures.length > 0) {
|
|
367
|
+
const text = failures.map((f) => `MCP server "${f.server.name}" failed: ${f.error}`).join("\n") + " (/mcp for status)";
|
|
368
|
+
if (ctx.hasUI) ctx.ui.notify(text, "warning");
|
|
369
|
+
else process.stderr.write(`${text}\n`);
|
|
370
|
+
}
|
|
371
|
+
if (oauthNeeded.size > 0 && ctx.hasUI) {
|
|
372
|
+
ctx.ui.notify(
|
|
373
|
+
`${oauthNeeded.size} MCP server${oauthNeeded.size === 1 ? "" : "s"} need authentication — run /mcp to authenticate.`,
|
|
374
|
+
"warning",
|
|
375
|
+
);
|
|
203
376
|
}
|
|
377
|
+
|
|
378
|
+
emitInstructions();
|
|
204
379
|
};
|
|
205
380
|
|
|
206
381
|
let resourceToolsRegistered = false;
|
|
@@ -217,7 +392,7 @@ export default function mcpExtension(pi: ExtensionAPI) {
|
|
|
217
392
|
parameters: Type.Object({
|
|
218
393
|
server: Type.Optional(Type.String({ description: "Limit to one server by name" })),
|
|
219
394
|
}),
|
|
220
|
-
async execute(
|
|
395
|
+
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
221
396
|
// A bad `server` name must not read back as "this server has zero
|
|
222
397
|
// resources" — validate it like read_mcp_resource does, else a typo
|
|
223
398
|
// looks like an empty (but real) server.
|
|
@@ -243,7 +418,12 @@ export default function mcpExtension(pi: ExtensionAPI) {
|
|
|
243
418
|
}
|
|
244
419
|
}
|
|
245
420
|
return {
|
|
246
|
-
content: [
|
|
421
|
+
content: [
|
|
422
|
+
{
|
|
423
|
+
type: "text",
|
|
424
|
+
text: lines.length ? persistIfLarge(lines.join("\n"), { dir: resultsDir(ctx), id: toolCallId }) : "No MCP resources available.",
|
|
425
|
+
},
|
|
426
|
+
],
|
|
247
427
|
details: { count: lines.length },
|
|
248
428
|
};
|
|
249
429
|
},
|
|
@@ -293,7 +473,7 @@ export default function mcpExtension(pi: ExtensionAPI) {
|
|
|
293
473
|
server: Type.String({ description: "Server name that owns the directory" }),
|
|
294
474
|
uri: Type.String({ description: "The directory resource uri to list" }),
|
|
295
475
|
}),
|
|
296
|
-
async execute(
|
|
476
|
+
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
297
477
|
const connection = connections.get(params.server);
|
|
298
478
|
if (!connection) return unknownServerError(params.server);
|
|
299
479
|
try {
|
|
@@ -301,7 +481,12 @@ export default function mcpExtension(pi: ExtensionAPI) {
|
|
|
301
481
|
const entries = (result.resources ?? result.entries ?? []) as Array<{ uri?: string; name?: string; mimeType?: string }>;
|
|
302
482
|
const lines = entries.map((e) => `${e.uri ?? "(no uri)"}${e.name ? ` — ${e.name}` : ""}${e.mimeType ? ` (${e.mimeType})` : ""}`);
|
|
303
483
|
return {
|
|
304
|
-
content: [
|
|
484
|
+
content: [
|
|
485
|
+
{
|
|
486
|
+
type: "text",
|
|
487
|
+
text: lines.length ? persistIfLarge(lines.join("\n"), { dir: resultsDir(ctx), id: toolCallId }) : "(empty directory)",
|
|
488
|
+
},
|
|
489
|
+
],
|
|
305
490
|
details: { server: params.server, uri: params.uri },
|
|
306
491
|
};
|
|
307
492
|
} catch (error) {
|
|
@@ -320,6 +505,10 @@ export default function mcpExtension(pi: ExtensionAPI) {
|
|
|
320
505
|
};
|
|
321
506
|
|
|
322
507
|
pi.on("session_start", async (_event, ctx) => {
|
|
508
|
+
// A new session (/clear, resume in a new process) has no cached prefix yet:
|
|
509
|
+
// its message-1 blocks are written fresh (same reset as tool-search).
|
|
510
|
+
requestSent = false;
|
|
511
|
+
announced = emptyAnnounced();
|
|
323
512
|
// pi awaits session_start handlers serially before the prompt opens, and
|
|
324
513
|
// remote servers take seconds to answer — awaiting here was the entire
|
|
325
514
|
// "slow startup" (4.9s → 0.24s measured, findings §15). In the interactive
|
|
@@ -336,36 +525,347 @@ export default function mcpExtension(pi: ExtensionAPI) {
|
|
|
336
525
|
});
|
|
337
526
|
connecting.finally(() => {
|
|
338
527
|
connectSettled = true;
|
|
528
|
+
// The session may be gone by now (/clear while a remote server was still
|
|
529
|
+
// answering): pi's API throws from assertActive after session_shutdown,
|
|
530
|
+
// and from a promise continuation that is an uncaught exception that
|
|
531
|
+
// killed the process (STEERING-REVIEW-2026-09-05 H3, measured).
|
|
532
|
+
if (!alive()) return;
|
|
533
|
+
// Final publish, marked settled — emitted even with zero servers/tools so
|
|
534
|
+
// a consumer that spawns children early (subagents) can stop waiting for
|
|
535
|
+
// late-connecting servers instead of snapshotting an empty set forever.
|
|
536
|
+
pi.events.emit(MCP_TOOLS_CHANNEL, { tools: [...sharedTools], settled: true });
|
|
339
537
|
});
|
|
340
|
-
if (!ctx.hasUI
|
|
538
|
+
if (!ctx.hasUI) {
|
|
341
539
|
await connecting;
|
|
342
540
|
}
|
|
343
541
|
});
|
|
344
542
|
|
|
345
543
|
pi.on("session_shutdown", async () => {
|
|
346
|
-
shuttingDown = true;
|
|
347
544
|
await Promise.all([...connections.values()].map((connection) => close(connection)));
|
|
348
545
|
connections.clear();
|
|
349
546
|
});
|
|
350
547
|
|
|
548
|
+
// Single source of truth for a server's status, shared by the /plugins status
|
|
549
|
+
// snapshot, the /mcp panel entries, and the text fallback.
|
|
550
|
+
const deriveStatus = (server: McpServer): { status: McpEntryStatus; detail?: string; toolCount?: number } => {
|
|
551
|
+
if (disabledNames.has(server.name)) return { status: "disabled" };
|
|
552
|
+
const withheld = withheldNames.get(server.name);
|
|
553
|
+
if (withheld) return { status: "disabled", detail: withheld };
|
|
554
|
+
const connection = connections.get(server.name);
|
|
555
|
+
if (connection) {
|
|
556
|
+
// A server can connect yet warn (e.g. listTools/listResources failed);
|
|
557
|
+
// adoptConnection files those under `failures`. Surface it so the warning
|
|
558
|
+
// isn't collected-but-invisible.
|
|
559
|
+
const warning = failures.find((f) => f.server.name === server.name);
|
|
560
|
+
return { status: "connected", toolCount: connection.tools.length, detail: warning?.error };
|
|
561
|
+
}
|
|
562
|
+
if (oauthNeeded.has(server.name)) return { status: "authNeeded" };
|
|
563
|
+
if (server.missingEnv?.length) {
|
|
564
|
+
return { status: "authNeeded", detail: `${server.missingEnv.join(", ")} not set in the environment` };
|
|
565
|
+
}
|
|
566
|
+
const failure = failures.find((f) => f.server.name === server.name);
|
|
567
|
+
if (failure) return { status: "failed", detail: failure.error };
|
|
568
|
+
return connectSettled ? { status: "failed", detail: "not connected" } : { status: "connecting" };
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
// The /plugins panel can't import this extension's state (jiti gives every
|
|
572
|
+
// extension its own module instance), and the bus doesn't replay — so it
|
|
573
|
+
// asks: a request event answered with a status snapshot.
|
|
574
|
+
const buildStatusSnapshot = (): McpServerStatus[] =>
|
|
575
|
+
servers.map((server) => {
|
|
576
|
+
const { status, detail, toolCount } = deriveStatus(server);
|
|
577
|
+
return { name: server.name, status, detail, toolCount, source: server.source };
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
pi.events.on(MCP_STATUS_REQUEST_CHANNEL, () => {
|
|
581
|
+
pi.events.emit(MCP_STATUS_CHANNEL, { servers: buildStatusSnapshot(), settled: connectSettled } satisfies McpStatusEvent);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// --- /mcp panel ------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
const shorten = (p: string) => (p.startsWith(home) ? `~${p.slice(home.length)}` : p);
|
|
587
|
+
|
|
588
|
+
// One place that turns a server's config source into its display group, sort
|
|
589
|
+
// rank, config-location label, and disable-persistence scope — so the four
|
|
590
|
+
// can't drift apart. Plugin servers persist at project scope (mcp-overrides
|
|
591
|
+
// has no plugin scope; per-repo is the right default for a plugin).
|
|
592
|
+
interface Provenance {
|
|
593
|
+
rank: number;
|
|
594
|
+
group: string;
|
|
595
|
+
configLocation: string;
|
|
596
|
+
scope: "user" | "project";
|
|
597
|
+
}
|
|
598
|
+
const classify = (server: McpServer): Provenance => {
|
|
599
|
+
if (pluginConfigPaths.has(server.source)) {
|
|
600
|
+
return { rank: 3, group: "Plugin MCPs", configLocation: "Plugin configuration", scope: "project" };
|
|
601
|
+
}
|
|
602
|
+
if (server.source === join(home, ".claude.json")) {
|
|
603
|
+
return { rank: 0, group: `User MCPs (${shorten(server.source)})`, configLocation: shorten(server.source), scope: "user" };
|
|
604
|
+
}
|
|
605
|
+
if (server.source.endsWith(join(".claude", "settings.local.json"))) {
|
|
606
|
+
return { rank: 2, group: "Project MCPs (.claude/settings.local.json)", configLocation: shorten(server.source), scope: "project" };
|
|
607
|
+
}
|
|
608
|
+
return { rank: 1, group: `Project MCPs (${shorten(server.source)})`, configLocation: shorten(server.source), scope: "project" };
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const buildEntries = (): McpEntry[] =>
|
|
612
|
+
servers
|
|
613
|
+
.map((server) => {
|
|
614
|
+
const { status, detail, toolCount } = deriveStatus(server);
|
|
615
|
+
const { rank, group, configLocation } = classify(server);
|
|
616
|
+
const canAuthenticate = server.kind === "http" && !server.missingEnv?.length;
|
|
617
|
+
// Issue line for hard failures, for an env-missing authNeeded (so the user
|
|
618
|
+
// sees which variable to set), and for a connected-but-warning server; an
|
|
619
|
+
// OAuth authNeeded needs no issue — the Authenticate action speaks for itself.
|
|
620
|
+
const issue =
|
|
621
|
+
status === "failed" || status === "connected" || status === "disabled" || (status === "authNeeded" && !canAuthenticate)
|
|
622
|
+
? detail
|
|
623
|
+
: undefined;
|
|
624
|
+
// Auth line shown on a failed http server (as Claude Code does), read
|
|
625
|
+
// honestly from whether tokens are stored — not just "failed http".
|
|
626
|
+
const authState =
|
|
627
|
+
status === "failed" && server.kind === "http"
|
|
628
|
+
? hasStoredTokens(server.name)
|
|
629
|
+
? ("authenticated" as const)
|
|
630
|
+
: ("notAuthenticated" as const)
|
|
631
|
+
: undefined;
|
|
632
|
+
const entry: McpEntry = {
|
|
633
|
+
name: server.name,
|
|
634
|
+
group,
|
|
635
|
+
status,
|
|
636
|
+
toolCount,
|
|
637
|
+
issue,
|
|
638
|
+
url: server.kind === "http" ? server.url : undefined,
|
|
639
|
+
configLocation,
|
|
640
|
+
authState,
|
|
641
|
+
canAuthenticate,
|
|
642
|
+
};
|
|
643
|
+
return { entry, rank };
|
|
644
|
+
})
|
|
645
|
+
.sort((a, b) =>
|
|
646
|
+
a.rank !== b.rank
|
|
647
|
+
? a.rank - b.rank
|
|
648
|
+
: a.entry.group === b.entry.group
|
|
649
|
+
? a.entry.name.localeCompare(b.entry.name)
|
|
650
|
+
: a.entry.group.localeCompare(b.entry.group),
|
|
651
|
+
)
|
|
652
|
+
.map((e) => e.entry);
|
|
653
|
+
|
|
654
|
+
/** Bounded dock like /skills and /plugins — keeps the transcript visible above. */
|
|
655
|
+
const MCP_PANEL_MAX_HEIGHT = 22;
|
|
656
|
+
|
|
657
|
+
const openMcpPanel = async (ctx: ExtensionContext): Promise<void> => {
|
|
658
|
+
await ctx.ui.custom<null>((tui, theme, _keybindings, done) => {
|
|
659
|
+
const paint: McpPaint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
|
|
660
|
+
const state = initialMcpState();
|
|
661
|
+
let notices: string[] = [];
|
|
662
|
+
const busy = new Set<string>();
|
|
663
|
+
let cache: { width: number; lines: string[] } | undefined;
|
|
664
|
+
// The entry list is derived from live connection state; build it once and
|
|
665
|
+
// rebuild only when that state changes (an action lands, or the ticker
|
|
666
|
+
// sees connects still settling) — never per render frame or per keystroke.
|
|
667
|
+
let entries = buildEntries();
|
|
668
|
+
const repaint = () => {
|
|
669
|
+
cache = undefined;
|
|
670
|
+
tui.requestRender();
|
|
671
|
+
};
|
|
672
|
+
const syncRepaint = () => {
|
|
673
|
+
entries = buildEntries();
|
|
674
|
+
repaint();
|
|
675
|
+
};
|
|
676
|
+
// Animate while connecting or an action is in flight; real changes repaint explicitly.
|
|
677
|
+
const ticker = setInterval(() => {
|
|
678
|
+
if (busy.size > 0 || !connectSettled) syncRepaint();
|
|
679
|
+
}, 500);
|
|
680
|
+
ticker.unref?.();
|
|
681
|
+
|
|
682
|
+
// Message after a (re)connect attempt: silent on success, else name the
|
|
683
|
+
// reason from the freshly derived status (auth vs a hard failure).
|
|
684
|
+
const outcomeNotice = (server: McpServer): string[] => {
|
|
685
|
+
const status = deriveStatus(server).status;
|
|
686
|
+
if (status === "connected") return [];
|
|
687
|
+
if (status === "authNeeded") return [`"${server.name}" needs authentication.`];
|
|
688
|
+
return [`"${server.name}" could not connect.`];
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const runReconnect = async (entry: McpEntry) => {
|
|
692
|
+
const server = servers.find((s) => s.name === entry.name);
|
|
693
|
+
if (!server || busy.has(entry.name)) return;
|
|
694
|
+
busy.add(entry.name);
|
|
695
|
+
notices = [`Reconnecting to "${entry.name}"…`];
|
|
696
|
+
syncRepaint();
|
|
697
|
+
const existing = connections.get(entry.name);
|
|
698
|
+
if (existing) {
|
|
699
|
+
connections.delete(entry.name);
|
|
700
|
+
await close(existing);
|
|
701
|
+
}
|
|
702
|
+
await connectOne(server);
|
|
703
|
+
busy.delete(entry.name);
|
|
704
|
+
// The session may have been replaced during the connect (an RPC
|
|
705
|
+
// new_session while the panel was open): every pi.* call throws
|
|
706
|
+
// then, and from a void'ed continuation that is an unhandled
|
|
707
|
+
// rejection (LIFECYCLE-REVIEW-2026-09-06 L2).
|
|
708
|
+
if (!alive()) return;
|
|
709
|
+
emitInstructions();
|
|
710
|
+
notices = outcomeNotice(server);
|
|
711
|
+
syncRepaint();
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const runDisable = (entry: McpEntry) => {
|
|
715
|
+
const server = servers.find((s) => s.name === entry.name);
|
|
716
|
+
if (!server || busy.has(entry.name)) return; // don't cut across an in-flight connect
|
|
717
|
+
setMcpServerDisabled(entry.name, true, classify(server).scope, ctx.cwd, home);
|
|
718
|
+
disabledNames.add(entry.name);
|
|
719
|
+
const existing = connections.get(entry.name);
|
|
720
|
+
if (existing) {
|
|
721
|
+
connections.delete(entry.name);
|
|
722
|
+
void close(existing);
|
|
723
|
+
}
|
|
724
|
+
state.detail = undefined;
|
|
725
|
+
emitInstructions(); // drops the disabled server's instructions reminder
|
|
726
|
+
notices = [`Disabled "${entry.name}". Its tools stay unavailable until re-enabled.`];
|
|
727
|
+
syncRepaint();
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const runEnable = async (entry: McpEntry) => {
|
|
731
|
+
const server = servers.find((s) => s.name === entry.name);
|
|
732
|
+
if (!server || busy.has(entry.name)) return;
|
|
733
|
+
setMcpServerDisabled(entry.name, false, classify(server).scope, ctx.cwd, home);
|
|
734
|
+
disabledNames.delete(entry.name);
|
|
735
|
+
// Enabling a withheld project .mcp.json server by hand IS the consent.
|
|
736
|
+
if (withheldNames.delete(entry.name)) persistApproval(projectRootOf(server), server);
|
|
737
|
+
busy.add(entry.name);
|
|
738
|
+
notices = [`Enabling "${entry.name}"…`];
|
|
739
|
+
syncRepaint();
|
|
740
|
+
await connectOne(server);
|
|
741
|
+
busy.delete(entry.name);
|
|
742
|
+
if (!alive()) return; // see runReconnect
|
|
743
|
+
emitInstructions();
|
|
744
|
+
notices = outcomeNotice(server);
|
|
745
|
+
syncRepaint();
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const runAuthenticate = async (entry: McpEntry) => {
|
|
749
|
+
const server = servers.find((s) => s.name === entry.name);
|
|
750
|
+
if (!server || busy.has(entry.name)) return;
|
|
751
|
+
if (server.kind !== "http") {
|
|
752
|
+
notices = [`"${entry.name}" is not an http server; OAuth is unavailable.`];
|
|
753
|
+
repaint();
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
busy.add(entry.name);
|
|
757
|
+
notices = [`Starting authorization for "${entry.name}"…`];
|
|
758
|
+
syncRepaint();
|
|
759
|
+
try {
|
|
760
|
+
const connection = await runOAuthFlow({
|
|
761
|
+
server,
|
|
762
|
+
home,
|
|
763
|
+
onPrompt: (message) => {
|
|
764
|
+
notices = message.split("\n");
|
|
765
|
+
repaint();
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
if (!alive()) {
|
|
769
|
+
await close(connection); // see runReconnect
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
adoptConnection(server, connection);
|
|
773
|
+
emitInstructions();
|
|
774
|
+
notices = [`Authenticated "${entry.name}".`];
|
|
775
|
+
} catch (error) {
|
|
776
|
+
notices = [`Authentication failed: ${(error as Error).message}`];
|
|
777
|
+
} finally {
|
|
778
|
+
busy.delete(entry.name);
|
|
779
|
+
syncRepaint();
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
// Each action is a void'ed promise. A throw after the session was
|
|
784
|
+
// replaced is expected (every pi.* call throws then) and dropped; any
|
|
785
|
+
// other failure is shown in the panel, never an unhandled rejection.
|
|
786
|
+
const guarded = (work: Promise<void>) =>
|
|
787
|
+
work.catch((error) => {
|
|
788
|
+
if (!alive()) return;
|
|
789
|
+
notices = [`Action failed: ${(error as Error).message}`];
|
|
790
|
+
syncRepaint();
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
const runEffect = (effect: McpEffect) => {
|
|
794
|
+
switch (effect.kind) {
|
|
795
|
+
case "close":
|
|
796
|
+
clearInterval(ticker);
|
|
797
|
+
done(null);
|
|
798
|
+
return;
|
|
799
|
+
case "reconnect":
|
|
800
|
+
void guarded(runReconnect(effect.entry));
|
|
801
|
+
return;
|
|
802
|
+
case "disable":
|
|
803
|
+
runDisable(effect.entry);
|
|
804
|
+
return;
|
|
805
|
+
case "enable":
|
|
806
|
+
void guarded(runEnable(effect.entry));
|
|
807
|
+
return;
|
|
808
|
+
case "authenticate":
|
|
809
|
+
void guarded(runAuthenticate(effect.entry));
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
render: (width: number) => {
|
|
816
|
+
if (cache?.width === width) return cache.lines;
|
|
817
|
+
const termRows = (tui as { terminal: { rows: number } }).terminal.rows;
|
|
818
|
+
const height = boundedDockHeight(termRows, MCP_PANEL_MAX_HEIGHT);
|
|
819
|
+
const lines = renderMcpPanel(
|
|
820
|
+
{ state, entries, width, height, notices, settled: connectSettled },
|
|
821
|
+
paint,
|
|
822
|
+
).map((line) => truncateLine(line, width));
|
|
823
|
+
cache = { width, lines };
|
|
824
|
+
return lines;
|
|
825
|
+
},
|
|
826
|
+
handleInput: (data: string) => {
|
|
827
|
+
const key = decodeMcpKey(data);
|
|
828
|
+
if (!key) return;
|
|
829
|
+
// Navigation reads the cached entries; effects (which change the
|
|
830
|
+
// underlying state) rebuild them via syncRepaint inside their handlers.
|
|
831
|
+
const effect = applyMcpKey(state, key, entries);
|
|
832
|
+
if (effect) runEffect(effect);
|
|
833
|
+
repaint();
|
|
834
|
+
},
|
|
835
|
+
invalidate: () => {
|
|
836
|
+
cache = undefined;
|
|
837
|
+
},
|
|
838
|
+
dispose: () => {
|
|
839
|
+
clearInterval(ticker);
|
|
840
|
+
},
|
|
841
|
+
};
|
|
842
|
+
});
|
|
843
|
+
};
|
|
844
|
+
|
|
351
845
|
pi.registerCommand("mcp", {
|
|
352
|
-
description: "
|
|
846
|
+
description: "Manage MCP servers (status, reconnect, authenticate, enable/disable)",
|
|
353
847
|
handler: async (_args, ctx) => {
|
|
354
848
|
if (servers.length === 0) {
|
|
355
849
|
ctx.ui.notify("No MCP servers configured. Add them to .mcp.json or ~/.claude.json.", "info");
|
|
356
850
|
return;
|
|
357
851
|
}
|
|
852
|
+
if (ctx.hasUI) {
|
|
853
|
+
await openMcpPanel(ctx);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
// Non-interactive fallback: a flat status listing, with tool/resource
|
|
857
|
+
// counts and any warning for connected servers.
|
|
358
858
|
const lines = servers.map((server) => {
|
|
859
|
+
const { status, detail } = deriveStatus(server);
|
|
359
860
|
const connection = connections.get(server.name);
|
|
861
|
+
let suffix = "";
|
|
360
862
|
if (connection) {
|
|
361
|
-
|
|
362
|
-
|
|
863
|
+
suffix = ` — ${connection.tools.length} tools, ${connection.resources.length} resources`;
|
|
864
|
+
if (detail) suffix += ` — WARNING: ${detail}`;
|
|
865
|
+
} else if (detail) {
|
|
866
|
+
suffix = ` — ${detail}`;
|
|
363
867
|
}
|
|
364
|
-
|
|
365
|
-
if (failure) return `failed ${server.name} — ${failure.error} (${server.source})`;
|
|
366
|
-
return connectSettled
|
|
367
|
-
? `failed ${server.name} — not connected (${server.source})`
|
|
368
|
-
: `… ${server.name} — still connecting (${server.source})`;
|
|
868
|
+
return `${status.padEnd(12)} ${server.name}${suffix} (${server.source})`;
|
|
369
869
|
});
|
|
370
870
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
371
871
|
},
|