claude-code - 💡(How to fix) Fix MCP collision check incorrectly suppresses claude.ai connectors that point to a different workspace than the same-named plugin — and there is no scriptable workaround

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

When a user has both a built-in plugin MCP (e.g. plugin:slack:slack, plugin:atlassian:atlassian) and the corresponding claude.ai-side connector (e.g. claude.ai Slack, claude.ai Atlassian) intentionally pointed at different upstream workspaces / instances, the /mcp UI suppresses the claude.ai connector with:

claude.ai Slack · ◌ hidden — same URL as your server 'plugin:slack:slack' To use this connector instead, disable the plugin server in /plugins

The two connectors are not duplicates: they authenticate to different workspaces, hold different OAuth tokens, and expose different data. The collision detector matches on a normalized URL signature that strips the very identity that distinguishes them.

The only known workaround is a manual /mcp UI dance (disable both plugins → quit and restart Claude Code → re-enable both plugins) that must be repeated on every cold restart. A scripted version — even one that edits the exact file the UI Disable button writes — does not work, because the UI does two things (file write + in-memory plugin disconnect/load) and the file write alone is insufficient. Detail in "Why no scripted workaround exists" below.

This is closely related to (but materially different from) #39511, which was closed as "not planned." That issue described colliding with unconnected claude.ai connectors. This issue is about valid, intentional, simultaneously-active dual-connector configurations that the current logic makes unusable.

Root Cause

Verified against Claude Code 2.1.139 (Bun-bundled SEA, extracted from the macOS arm64 binary). Minified identifiers rotate every release; the stable anchors are the log strings, which a maintainer can grep on for the corresponding source lines. Names below are from 2.1.139.

The URL-normalization function strips everything except ?mcp_url=... for URLs routing through the Anthropic MCP gateway:

VG7 = [
  "/v2/session_ingress/shttp/mcp/",
  "/v2/session_ingress/mcp/ws/",
  "/v2/ccr-sessions/",
];

function vG7(H) {
  if (!VG7.some((_) => H.includes(_))) return H;
  try { return new URL(H).searchParams.get("mcp_url") || H; }
  catch { return H; }
}

That normalizer is called by the signature builder, which produces the collision key:

function $wH(H) {
  let _ = E16(H); if (_) return `stdio:${hH(_)}`;
  let q = S16(H); if (q) return `url:${vG7(q)}`;
  return null;
}

And the suppression itself happens in (search for the log string Suppressing claude.ai connector to locate this function in source):

function QJ_(H, _) {  // H = claude.ai connectors, _ = manually configured
  let q = L16() ?? {}, K = new Map();
  for (let [A, z] of Object.entries(_)) {
    // ...; build map from signature → {name, scope}
    let $ = $wH(z);
    if ($ && !K.has($)) K.set($, { name: A, scope: z.scope });
  }
  let O = {}, T = [];
  for (let [A, z] of Object.entries(H)) {
    let $ = $wH(z), Y = $ !== null ? K.get($) : void 0;
    if (Y !== void 0) {
      v(`Suppressing claude.ai connector "${A}": duplicates manually-configured "${Y.name}"`);
      T.push({ name: A, duplicateOf: Y.name, duplicateOfScope: Y.scope });
      continue;
    }
    O[A] = z;
  }
  return { servers: O, suppressed: T };
}

Both the plugin-side (type === "claudeai-proxy") and the claude.ai-side connector for the same product (Slack, Atlassian, etc.) carry the same mcp_url value when their URLs route through the gateway, because both ultimately target the same upstream MCP product endpoint. The piece that distinguishes them — the user's chosen workspace / OAuth identity — is not part of the URL and therefore not part of the collision key.

A symmetric path also exists for the inverse case (search for Suppressing plugin MCP server), but only the claude.ai-side suppression carries duplicateOfScope.

Fix Action

Fix / Workaround

MCP collision check incorrectly suppresses claude.ai connectors that point to a different workspace than the same-named plugin — and there is no scriptable workaround

The only known workaround is a manual /mcp UI dance (disable both plugins → quit and restart Claude Code → re-enable both plugins) that must be repeated on every cold restart. A scripted version — even one that edits the exact file the UI Disable button writes — does not work, because the UI does two things (file write + in-memory plugin disconnect/load) and the file write alone is insufficient. Detail in "Why no scripted workaround exists" below.

Manual workaround that currently fixes it (per restart):

Code Example

VG7 = [
  "/v2/session_ingress/shttp/mcp/",
  "/v2/session_ingress/mcp/ws/",
  "/v2/ccr-sessions/",
];

function vG7(H) {
  if (!VG7.some((_) => H.includes(_))) return H;
  try { return new URL(H).searchParams.get("mcp_url") || H; }
  catch { return H; }
}

---

function $wH(H) {
  let _ = E16(H); if (_) return `stdio:${hH(_)}`;
  let q = S16(H); if (q) return `url:${vG7(q)}`;
  return null;
}

---

function QJ_(H, _) {  // H = claude.ai connectors, _ = manually configured
  let q = L16() ?? {}, K = new Map();
  for (let [A, z] of Object.entries(_)) {
    // ...; build map from signature → {name, scope}
    let $ = $wH(z);
    if ($ && !K.has($)) K.set($, { name: A, scope: z.scope });
  }
  let O = {}, T = [];
  for (let [A, z] of Object.entries(H)) {
    let $ = $wH(z), Y = $ !== null ? K.get($) : void 0;
    if (Y !== void 0) {
      v(`Suppressing claude.ai connector "${A}": duplicates manually-configured "${Y.name}"`);
      T.push({ name: A, duplicateOf: Y.name, duplicateOfScope: Y.scope });
      continue;
    }
    O[A] = z;
  }
  return { servers: O, suppressed: T };
}
RAW_BUFFERClick to expand / collapse

MCP collision check incorrectly suppresses claude.ai connectors that point to a different workspace than the same-named plugin — and there is no scriptable workaround

Summary

When a user has both a built-in plugin MCP (e.g. plugin:slack:slack, plugin:atlassian:atlassian) and the corresponding claude.ai-side connector (e.g. claude.ai Slack, claude.ai Atlassian) intentionally pointed at different upstream workspaces / instances, the /mcp UI suppresses the claude.ai connector with:

claude.ai Slack · ◌ hidden — same URL as your server 'plugin:slack:slack' To use this connector instead, disable the plugin server in /plugins

The two connectors are not duplicates: they authenticate to different workspaces, hold different OAuth tokens, and expose different data. The collision detector matches on a normalized URL signature that strips the very identity that distinguishes them.

The only known workaround is a manual /mcp UI dance (disable both plugins → quit and restart Claude Code → re-enable both plugins) that must be repeated on every cold restart. A scripted version — even one that edits the exact file the UI Disable button writes — does not work, because the UI does two things (file write + in-memory plugin disconnect/load) and the file write alone is insufficient. Detail in "Why no scripted workaround exists" below.

This is closely related to (but materially different from) #39511, which was closed as "not planned." That issue described colliding with unconnected claude.ai connectors. This issue is about valid, intentional, simultaneously-active dual-connector configurations that the current logic makes unusable.

Reproduction

Configuration:

  • Slack: plugin:slack:slack authenticated to Workspace A; claude.ai Slack authenticated to Workspace B (different workspace, different OAuth identity).
  • Atlassian: plugin:atlassian:atlassian authenticated to Jira Instance A; claude.ai Atlassian authenticated to Jira Instance B (different cloud ID, different account ID).

After a fresh claude start, /mcp shows both claude.ai Slack and claude.ai Atlassian as hidden — same URL as your server 'plugin:...'. The plugin connectors function; the claude.ai connectors are unreachable until manually re-enabled.

Manual workaround that currently fixes it (per restart):

  1. /mcp → highlight plugin:slack:slack → Disable.
  2. /mcp → highlight plugin:atlassian:atlassian → Disable.
  3. Quit and restart Claude Code completely.
  4. /mcp → re-enable plugin:slack:slack.
  5. /mcp → re-enable plugin:atlassian:atlassian.

After step 5 all four connectors (both plugin-side and both claude.ai-side) appear and function simultaneously. The bug recurs on the next cold restart with both plugins enabled — at which point the collision suppression runs again at startup against the freshly-fetched claude.ai catalog.

Root cause

Verified against Claude Code 2.1.139 (Bun-bundled SEA, extracted from the macOS arm64 binary). Minified identifiers rotate every release; the stable anchors are the log strings, which a maintainer can grep on for the corresponding source lines. Names below are from 2.1.139.

The URL-normalization function strips everything except ?mcp_url=... for URLs routing through the Anthropic MCP gateway:

VG7 = [
  "/v2/session_ingress/shttp/mcp/",
  "/v2/session_ingress/mcp/ws/",
  "/v2/ccr-sessions/",
];

function vG7(H) {
  if (!VG7.some((_) => H.includes(_))) return H;
  try { return new URL(H).searchParams.get("mcp_url") || H; }
  catch { return H; }
}

That normalizer is called by the signature builder, which produces the collision key:

function $wH(H) {
  let _ = E16(H); if (_) return `stdio:${hH(_)}`;
  let q = S16(H); if (q) return `url:${vG7(q)}`;
  return null;
}

And the suppression itself happens in (search for the log string Suppressing claude.ai connector to locate this function in source):

function QJ_(H, _) {  // H = claude.ai connectors, _ = manually configured
  let q = L16() ?? {}, K = new Map();
  for (let [A, z] of Object.entries(_)) {
    // ...; build map from signature → {name, scope}
    let $ = $wH(z);
    if ($ && !K.has($)) K.set($, { name: A, scope: z.scope });
  }
  let O = {}, T = [];
  for (let [A, z] of Object.entries(H)) {
    let $ = $wH(z), Y = $ !== null ? K.get($) : void 0;
    if (Y !== void 0) {
      v(`Suppressing claude.ai connector "${A}": duplicates manually-configured "${Y.name}"`);
      T.push({ name: A, duplicateOf: Y.name, duplicateOfScope: Y.scope });
      continue;
    }
    O[A] = z;
  }
  return { servers: O, suppressed: T };
}

Both the plugin-side (type === "claudeai-proxy") and the claude.ai-side connector for the same product (Slack, Atlassian, etc.) carry the same mcp_url value when their URLs route through the gateway, because both ultimately target the same upstream MCP product endpoint. The piece that distinguishes them — the user's chosen workspace / OAuth identity — is not part of the URL and therefore not part of the collision key.

A symmetric path also exists for the inverse case (search for Suppressing plugin MCP server), but only the claude.ai-side suppression carries duplicateOfScope.

Why no scripted workaround exists (and the file-edit dead-end)

This matters for anyone hoping to automate around the bug.

Reasonable hypothesis: since the /mcp UI's Disable button writes to ~/.claude.jsonprojects[<cwd>].disabledMcpServers[], a script that edits the same array should be equivalent to clicking the button. It is not.

The UI Disable button does two things in one action:

  1. Persists the plugin name to disabledMcpServers in ~/.claude.json (visible to the next startup's collision check).
  2. Disconnects the plugin in-memory in the running Claude Code session, immediately.

The UI Enable button is the symmetric pair:

  1. Removes the plugin from disabledMcpServers.
  2. Loads the plugin's tool schemas into the running session, making it callable without a restart.

A script can do (1) trivially — jq can rewrite the array. A script cannot do (2): there is no publicly documented IPC mechanism we could locate that lets an external process disconnect or hot-load an MCP plugin inside a running Claude Code process.

The practical consequence, tested directly on 2026-05-11:

  • Script-driven "Phase 2 re-enable" after restart: the file is in the desired "both enabled" state, claude.ai-side registered cleanly at startup (no collision), but plugin tool schemas are not surfaced in the session — they were absent at startup and no hot-load happened on the file edit. The user then has to restart again to pick up the plugins. On that restart, disabledMcpServers is empty → collision detector runs against both plugins → claude.ai-side suppressed again → square one.

  • Script-driven "Phase 1 disable" before restart: harmless, because the user is about to restart anyway. But it provides no benefit over the UI Disable click either, since the script can't disconnect the plugin in-memory — which doesn't matter pre-restart.

In short: the UI dance works because each click does file + in-memory state. Any approach that only writes the file produces a split-brain session that requires another restart to recover, which puts the file back into the state that triggers suppression. The two operations have to happen together, and the in-memory half is not exposed.

For completeness, these alternatives also don't work:

  • Editing ~/.claude/settings.jsonenabledPlugins does nothing for MCP collision. That field belongs to the broader plugin/extension system and is not consulted by the collision logic.
  • Editing disabledMcpServers directly while Claude Code is running has no visible effect in the current session.

Suggested fix

The signature used for collision purposes should incorporate workspace / authentication identity, not just the upstream product URL. Concretely, one of:

  1. Include the connector's stable id (the z.id field that's already stored on claudeai-proxy configs alongside url, scope, and toolPermissions) in the signature built by $wH. That field uniquely identifies the upstream and is already present.
  2. Skip URL-based collision detection entirely for type === "claudeai-proxy" entries on the claude.ai-side, since they're managed server-side and the user has explicitly opted into them via claude.ai connector settings.
  3. Match only when the OAuth account / workspace identifiers are also equal.

Secondary ask, if (1)–(3) are out of scope: expose an IPC or CLI command — e.g. claude mcp reload <plugin> — that performs the in-memory disconnect/load the UI buttons do. That would at least make the workaround scriptable instead of requiring a five-step manual dance on every cold restart.

How #39511 differs

#39511 reported a benign cosmetic warning when a disconnected claude.ai connector triggered the same collision logic. It was reasonable to close that as not planned — the plugins worked.

This issue is different on two axes:

  • With both endpoints intentionally connected to different workspaces, the suppression is not cosmetic — the claude.ai connector is unreachable until the user performs the disable/restart/re-enable workaround, every time.
  • There is no scriptable workaround, because the file-edit equivalent of the UI dance does not produce the same in-memory state. This makes the dance not just annoying but unautomatable for users who hit it on every cold start.

Environment

  • Claude Code 2.1.139 (root-cause analysis verified against this version)
  • macOS 15.7.3 (Darwin 24.6.0, arm64)
  • Two plugin MCPs and two claude.ai connectors, intentionally pointed at distinct workspaces:
    • plugin:slack:slack → Workspace A; claude.ai Slack → Workspace B
    • plugin:atlassian:atlassian → Jira Instance A; claude.ai Atlassian → Jira Instance B

References

  • Closely related: #39511 (closed not-planned; cosmetic warning on disconnected claude.ai connectors)
  • Suppression log line: Suppressing claude.ai connector "X": duplicates manually-configured "Y"
  • Symmetric path log line: Suppressing plugin MCP server "X": duplicates manually-configured "Y"
  • Suppression UI string: hidden — same URL as your server '<name>' in Manage MCP servers

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING