Skip to content
Claude code plugin system bug 2
# The `[0]` That Broke Plugin Isolation: How One Array Index Bypassed Scope in Claude Code

## TL;DR

A single `[0]` array index in Claude Code's plugin loader caused it to load the wrong plugin version — ignoring project scope entirely. If you installed the same plugin in multiple projects at different versions, the first project to install it permanently determined which version ran everywhere. Old MCP servers, deprecated commands, and missing features leaked across projects. The fix is one line.

---

## The Mystery: Ghost Tools From a Dead Plugin

It started with a simple observation: MCP tools that shouldn't exist were appearing in a fresh Claude Code session.

The terminal plugin had undergone a major rewrite at v4.0.0 — replacing the deprecated `ht-mcp` server with a new Go-based `tmux-mcp` binary. The project in question (a Go web app called "meroku") had terminal v4.0.2 installed. Every diagnostic confirmed it:

- `claudeup` TUI showed **terminal v4.0.2**
- Project `settings.json` had `"terminal@magus": "4.0.2"` in `installedPluginVersions`
- The plugin cache at `~/.claude/plugins/cache/magus/terminal/4.0.2/.mcp.json` declared only `tmux`

Yet the session loaded six `mcp__ht__*` tools — tools from the deprecated `ht-mcp` server that was removed two major versions ago. Alongside them, old commands like `/deep-research` appeared instead of their replacements. New commands were missing entirely.

Something was loading the wrong version of the plugin. But where?

---

## The Investigation: Ruling Out the Obvious

### Theory 1: Stale docs confusing the AI

The terminal plugin's `DEPENDENCIES.md` and `README.md` still referenced `ht-mcp` — leftovers from before v4.0.0. Could Claude be reading those docs and hallucinating the tools?

**Verdict: Ruled out.** The `mcp__ht__*` tools appeared in the deferred tools list — that's populated by actual MCP server connections, not documentation. If the server wasn't running, the tools wouldn't appear.

### Theory 2: Global MCP config

Maybe a user-level `.mcp.json` was declaring `ht-mcp`?

**Verdict: Ruled out.** No `~/.claude/.mcp.json` existed. No user-scope terminal plugin installation either.

### Theory 3: Cross-project scope leakage

The machine had 21 projects with the terminal plugin installed. Only 3 had v4.0.2. The other 18 were on v1.0.0–v2.1.0, all of which declared `ht-mcp` in their `.mcp.json`.

Could Claude Code be loading MCP servers from ALL project-scoped installations, not just the current one?

**Verdict: Getting warmer.** But this needed proof.

---

## Finding the Source: Reverse-Engineering the Plugin Loader

Claude Code ships as a compiled Bun binary — no readable source. But `strings` on the binary extracted enough minified JavaScript to find function signatures and data flow patterns.

The first clue was an existing function designed exactly for this purpose:

```typescript
// installedPluginsManager.ts, lines 800–808
export function isInstallationRelevantToCurrentProject(
inst: PluginInstallationEntry,
): boolean {
return (
inst.scope === 'user' ||
inst.scope === 'managed' ||
inst.projectPath === getOriginalCwd()
)
}
```

This function correctly filters plugin installations by scope. It was used by UI functions like `isPluginInstalled()`. But was the plugin *loader* using it?

With access to the Claude Code source repository, the answer became clear at `pluginLoader.ts` line 2051:

```typescript
const installEntry = installedPluginsData.plugins[pluginId]?.[0]
```

**There it is.** `[0]`. The first element. No filtering.

---

## The Root Cause: Array Index vs. Scope Filter

### How `installed_plugins.json` works

Claude Code uses a single user-level file (`~/.claude/plugins/installed_plugins.json`) to track all plugin installations across all projects. The v2 schema stores an array of entries per plugin ID:

```json
{
"version": 2,
"plugins": {
"terminal@magus": [
{
"scope": "project",
"projectPath": "/Users/jack/mag/website",
"installPath": "~/.claude/plugins/cache/magus/terminal/2.0.0",
"version": "2.0.0"
},
{
"scope": "project",
"projectPath": "/Users/jack/mag/meroku",
"installPath": "~/.claude/plugins/cache/magus/terminal/4.0.2",
"version": "4.0.2"
}
]
}
}
```

The array is ordered by insertion time — whichever project installed the plugin first gets index `[0]`.

### What the loader does

```typescript
// pluginLoader.ts:2051 — THE BUG
const installEntry = installedPluginsData.plugins[pluginId]?.[0]

// Uses installEntry.installPath to load ALL plugin content:
// - MCP servers (.mcp.json)
// - Commands (commands/)
// - Skills (skills/)
// - Agents (agents/)
// - Hooks (hooks/)
```

It takes the first entry regardless of which project you're in. The `projectPath` field — the entire purpose of the v2 schema — is ignored at the most critical moment: when deciding which code to run.

### What the loader should do

```typescript
// THE FIX — one line
const entries = installedPluginsData.plugins[pluginId] ?? []
const installEntry =
entries.find(isInstallationRelevantToCurrentProject) ?? entries[0]
```

The filter function already exists. It was just never called here.

---

## Impact: Worse Than Wrong Versions

This isn't just "you see v2.0.0 instead of v4.0.2" in a UI. The loader reads the actual plugin directory — every file in it:

| What Loads Wrong | Consequence |
|---|---|
| **MCP servers** | Deprecated servers start and connect. In this case, `ht-mcp` (removed in v4.0.0) was loaded, injecting 6 phantom tools into every session. |
| **Commands** | Old command names appear (`/deep-research`), new ones don't (`/dev:research`). Users invoke deprecated workflows. |
| **Skills** | AI agents receive outdated skill descriptions and instructions. |
| **Hooks** | Pre/post tool-use hooks from old versions fire — potentially with incompatible assumptions about the codebase. |
| **Agents** | Agent definitions reference tools and capabilities that no longer exist. |

Meanwhile, every diagnostic tool reports the correct version. The project's `settings.json`, the claudeup TUI, the `/plugins` command — all say v4.0.2. There is no indication that the runtime is loading v2.0.0
`settings.json`, the claudeup TUI, the `/plugins` command — all say v4.0.2. There is no indication that the runtime is loading v2.0.0.

### The scope system is decorative

The `scope` and `projectPath` fields are recorded on install and checked by UI functions (`isPluginInstalled`, `isPluginGloballyInstalled`, `DiscoverPlugins.tsx`). But the only place that matters — the actual content loader — skips the check. Project scope has no power over what code runs.

---

## The Numbers

On the affected machine:

- **21 projects** had `terminal@magus` installed
- **18 projects** were on v1.0.0–v2.1.0 (with deprecated `ht-mcp`)
- **3 projects** were on v4.0.2 (current)
- The **first entry** in `installed_plugins.json` was a v2.0.0 installation from a project called `website`
- Every single project — including the 3 with v4.0.2 — was loading terminal plugin content from the `website` project's v2.0.0 cache directory

The same pattern affected other plugins too. The `dev` plugin had old command names leaking from v1.x installations while projects had v2.7.0.

---

## Step-by-Step Reproduction

You can reproduce this with any marketplace plugin. Here's a minimal example:

### Prerequisites

- Claude Code 2.1.x with plugin support
- A marketplace with a plugin that has changed significantly between versions (different MCP servers, different commands)

### Steps

```bash
# 1. Create two test projects
mkdir -p ~/test/project-a ~/test/project-b

# 2. Install the plugin in project-a at an older version
cd ~/test/project-a
claude plugin install terminal@magus
# This creates the FIRST entry in installed_plugins.json
# Let's say the marketplace currently has v2.0.0

# 3. Time passes. The plugin releases v4.0.2 with breaking changes:
# - Removes ht-mcp MCP server
# - Adds tmux-mcp MCP server
# - Renames commands

# 4. Install the plugin in project-b at the new version
cd ~/test/project-b
claude plugin install terminal@magus
# This creates the SECOND entry in installed_plugins.json

# 5. Verify the registry
python3 -c "
import json
with open('$HOME/.claude/plugins/installed_plugins.json') as f:
data = json.load(f)
for entry in data['plugins'].get('terminal@magus', []):
print(f\"v={entry['version']} path={entry['projectPath']} install={entry['installPath']}\")
"
# Output:
# v=2.0.0 path=/Users/you/test/project-a install=~/.claude/plugins/cache/.../2.0.0
# v=4.0.2 path=/Users/you/test/project-b install=~/.claude/plugins/cache/.../4.0.2

# 6. Start Claude in project-b
cd ~/test/project-b
claude

# 7. Inside the session, check what loaded:
# - /mcp will show ht-mcp connected (from v2.0.0) ← WRONG
# - Old commands appear, new commands missing ← WRONG
# - settings.json shows v4.0.2 ← CORRECT (but misleading)
```

### What you'll see

In the Claude session started in `project-b`:

```
# Deferred tools list includes ghost tools from v2.0.0:
mcp__ht__ht_create_session ← should not exist (removed in v4.0.0)
mcp__ht__ht_send_keys ← should not exist
mcp__ht__ht_take_snapshot ← should not exist

# Old commands appear:
/deep-research ← renamed to /dev:research in v2.7.0

# New commands missing:
/dev:investigate ← exists in v4.0.2 but not loaded
/terminal:run ← exists in v4.0.2 but not loaded
```

### Verification: confirm the wrong path is used

Start Claude with debug logging to see which `installPath` the loader reads:

```bash
cd ~/test/project-b
claude --debug 2>&1 | tee /tmp/claude-debug.log

# After session starts, check:
grep "Loading plugin" /tmp/claude-debug.log
# Will show the plugin loading from project-a's cache path
```

---

## Code Walkthrough: How the Bug Executes

### Step 1: Session starts, loader reads enabled plugins

```typescript
// pluginLoader.ts — loadAllPlugins()
async function loadAllPlugins({ cacheOnly }) {
const settings = getSettings_DEPRECATED()
const enabledPlugins = {
...getDefaultEnabledPlugins(),
...(settings.enabledPlugins || {}),
}
// enabledPlugins = { "terminal@magus": true, "dev@magus": true, ... }
```

This reads the project's `settings.json` `enabledPlugins` — which correctly says terminal@magus is enabled. But it doesn't indicate which version.

### Step 2: For each plugin, look up marketplace entry

```typescript
// For each enabled plugin, find its marketplace entry
const result = await getPluginByIdCacheOnly(pluginId)
// result.entry = { name: "terminal", source: { ... }, version: "4.0.2" }
// The marketplace entry has the CORRECT version
```

### Step 3: THE BUG — look up installPath from registry

```typescript
// installed_plugins.json records what's actually cached on disk
const installEntry = installedPluginsData.plugins[pluginId]?.[0]
// ^^^
// Takes FIRST entry. On this machine, [0] is:
// { projectPath: "/Users/jack/mag/website",
// installPath: "~/.cache/.../terminal/2.0.0",
// version: "2.0.0" }
//
// The entry for the CURRENT project (meroku, v4.0.2) is at index [7]
```

### Step 4: Load plugin content from wrong directory

Want to print your doc?
This is not the way.
Try clicking the ··· in the right corner or using a keyboard shortcut (
CtrlP
) instead.