Skip to content

Commit 9dddd4d

Browse files
committed
feat(#47): interactive MCP name conflict resolution during apc collect
When multiple tools provide an MCP server with the same name, apc collect now detects the collision and prompts the user: ⚠ MCP server name conflict: 'my-mcp' # Source Tool Command Args 1 claude-code npx ... 2 cursor node ... Keep which as 'my-mcp'? [1-2]: 1 Entry from 'cursor': [r]ename to 'my-mcp-cursor' or [d]iscard? r - overwrite (discard): one canonical entry kept, other dropped - rename: non-canonical entry kept as '<name>-<source_tool>' - --yes: skips prompts, passes all entries through (merge handles dedup) Tests (21): - no-conflict cases: empty, single, different names, same tool - --yes mode: two-way, three-way, mixed conflict+clean - interactive overwrite: keep chosen, discard other, clean servers always present, invalid choice fallback, discard confirmation - interactive rename: suffix added, data preserved, canonical unchanged, three-way rename-all, three-way rename+discard, result counts - CLI integration: collect --yes with shared name, no prompt when clean
1 parent a43cedf commit 9dddd4d

File tree

3 files changed

+439
-486
lines changed

3 files changed

+439
-486
lines changed

src/collect.py

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,112 @@
3434
)
3535

3636

37+
def _resolve_mcp_conflicts(
38+
all_servers: List[Dict],
39+
yes: bool,
40+
) -> List[Dict]:
41+
"""Detect MCP server name collisions across tools and let the user resolve them.
42+
43+
A collision occurs when two or more tools provide an MCP server with the
44+
same name (e.g. both claude-code and cursor have a server called "my-mcp").
45+
46+
For each collision the user may:
47+
- overwrite → keep only one entry; the last-collected one is the default
48+
- rename → keep both, but suffix the non-default one with its source
49+
tool name (e.g. "my-mcp-cursor")
50+
51+
When --yes / non-interactive: last-collected wins (overwrite silently).
52+
"""
53+
if not all_servers:
54+
return []
55+
56+
# Group by server name to detect collisions
57+
from collections import defaultdict
58+
59+
by_name: Dict[str, List[Dict]] = defaultdict(list)
60+
for server in all_servers:
61+
by_name[server.get("name", "")].append(server)
62+
63+
collisions = {name: entries for name, entries in by_name.items() if len(entries) > 1}
64+
65+
if not collisions or yes:
66+
# No conflicts, or --yes: last-collected wins (standard merge behaviour)
67+
return all_servers
68+
69+
# Interactive resolution
70+
from rich.console import Console
71+
from rich.table import Table
72+
73+
console = Console()
74+
resolved: List[Dict] = []
75+
# Start with non-conflicting servers
76+
for name, entries in by_name.items():
77+
if len(entries) == 1:
78+
resolved.append(entries[0])
79+
80+
for name, entries in collisions.items():
81+
console.print(
82+
f"\n[bold yellow]⚠ MCP server name conflict:[/bold yellow] [bold]{name!r}[/bold]"
83+
)
84+
85+
tbl = Table(show_header=True, header_style="bold cyan", show_lines=False)
86+
tbl.add_column("#", style="dim", width=3)
87+
tbl.add_column("Source Tool", style="green")
88+
tbl.add_column("Command")
89+
tbl.add_column("Args")
90+
91+
for i, entry in enumerate(entries, 1):
92+
cmd = entry.get("command") or ""
93+
args = " ".join(str(a) for a in entry.get("args", []))
94+
tbl.add_row(str(i), entry.get("source_tool", "?"), cmd, args)
95+
96+
console.print(tbl)
97+
console.print(
98+
"[dim]Options: overwrite (keep one) or rename (keep both with tool suffix)[/dim]"
99+
)
100+
101+
# Pick which entry to keep as canonical
102+
default_idx = len(entries) # last-collected
103+
raw = click.prompt(
104+
f" Keep which as {name!r}? [1-{len(entries)}]",
105+
default=str(default_idx),
106+
).strip()
107+
try:
108+
keep_idx = int(raw) - 1
109+
if not (0 <= keep_idx < len(entries)):
110+
raise ValueError
111+
except ValueError:
112+
info(f" Invalid choice — keeping last-collected entry for {name!r}")
113+
keep_idx = len(entries) - 1
114+
115+
canonical = entries[keep_idx]
116+
others = [e for i, e in enumerate(entries) if i != keep_idx]
117+
118+
resolved.append(canonical)
119+
120+
# Ask about the others: rename or discard
121+
for other in others:
122+
src = other.get("source_tool", "other")
123+
new_name = f"{name}-{src}"
124+
choice = (
125+
click.prompt(
126+
f" Entry from {src!r}: [r]ename to {new_name!r} or [d]iscard?",
127+
default="r",
128+
)
129+
.strip()
130+
.lower()
131+
)
132+
if choice.startswith("r"):
133+
renamed = dict(other)
134+
renamed["name"] = new_name
135+
resolved.append(renamed)
136+
info(f" ✓ Renamed to {new_name!r}")
137+
else:
138+
info(f" ✓ Discarded {src!r} entry for {name!r}")
139+
140+
return resolved
141+
142+
37143
def _resolve_memory_conflicts(
38144
all_memory: List[Dict],
39145
yes: bool,
@@ -118,10 +224,14 @@ def collect(tools, no_memory, yes):
118224
scan_results_table(tool_counts)
119225

120226
# --- Phase 2: Conflict Resolution ---
227+
all_mcp_raw: List[Dict] = []
121228
all_memory_raw: List[Dict] = []
122229
for data in tool_extractions.values():
230+
all_mcp_raw.extend(data["mcp_servers"])
123231
all_memory_raw.extend(data["memory"])
124232

233+
resolved_mcp = _resolve_mcp_conflicts(all_mcp_raw, yes)
234+
125235
if all_memory_raw and not no_memory:
126236
selected_memory = _resolve_memory_conflicts(all_memory_raw, yes)
127237
else:
@@ -137,11 +247,10 @@ def collect(tools, no_memory, yes):
137247
header("Collecting")
138248

139249
new_skills = []
140-
new_mcp_servers = []
141-
142-
for tool_name, data in tool_extractions.items():
250+
for data in tool_extractions.values():
143251
new_skills.extend(data["skills"])
144-
new_mcp_servers.extend(data["mcp_servers"])
252+
253+
new_mcp_servers = resolved_mcp
145254

146255
# Add collected_at timestamp to selected memory entries
147256
now = datetime.now(timezone.utc).isoformat()

0 commit comments

Comments
 (0)