Skip to content

Commit 5cc8358

Browse files
jdneoCopilotxinyi-gong
authored
fix: harden MCP extension-point registration against races and stale state (#164)
- Synchronize all extMcpInfoMap accesses on the manager monitor so the HashMap is never iterated/mutated concurrently by the async doRegistration() worker, the contribution-point-policy event handler, and the UI-thread approval flow. - approveExtMcpRegistration() now snapshots the map under the lock and opens McpApprovalDialog outside the lock so the worker is not stalled while the user interacts; post-dialog reconciliation re-acquires the lock and the LSP-pushing event publish runs outside the lock. - Mark approvedExtMcpServers volatile and make hasExtMcpRegistration() synchronized for consistent visibility from the UI thread. - Avoid pre-populating in-memory approved servers from the persisted cache at startup; let doRegistration() refresh state and fire TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED so the LSP receives the verified set instead of stale data (uninstalled plugin, changed port, removed server). - Fix bundle-state guard in loadMcpRegistrationExtensionPoint() (|| -> &&) so already-active or starting bundles are not pointlessly started again. - LanguageServerSettingManager subscribes to the new completion topic and exposes an overload that accepts the approved JSON directly, avoiding the ChatServiceManager lookup from early-startup paths. - Add regression tests covering plugin uninstall, config change (re-approval required), and unchanged config (approval carry-over). Issue: #153 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: xinyi-gong <torigong@microsoft.com>
1 parent b2628ba commit 5cc8358

6 files changed

Lines changed: 277 additions & 45 deletions

File tree

com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,17 @@ public class CopilotEventConstants {
151151
*/
152152
public static final String TOPIC_MCP_SERVER_STATE_CHANGE = TOPIC_MCP + "SERVER_STATE_CHANGE";
153153

154+
/**
155+
* Event when the MCP registration extension-point manager has finished detecting and verifying
156+
* the contributed servers (either at startup or after a policy toggle / user approval) and the
157+
* approved-servers JSON for the language server is ready to be pushed.
158+
*
159+
* <p>Payload (via {@code IEventBroker.DATA}): the approved-servers JSON {@code String}, or
160+
* {@code null} when no extension-contributed servers are approved.
161+
*/
162+
public static final String TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED = TOPIC_MCP
163+
+ "EXTENSION_POINT_REGISTRATION_COMPLETED";
164+
154165
/**
155166
* Event when NES suggestion is accepted.
156167
*/

com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
package com.microsoft.copilot.eclipse.ui.chat.services;
55

66
import static org.junit.jupiter.api.Assertions.assertEquals;
7+
import static org.junit.jupiter.api.Assertions.assertFalse;
78
import static org.junit.jupiter.api.Assertions.assertNotNull;
89
import static org.junit.jupiter.api.Assertions.assertTrue;
910
import static org.mockito.ArgumentMatchers.eq;
11+
import static org.mockito.Mockito.atLeastOnce;
1012
import static org.mockito.Mockito.mock;
13+
import static org.mockito.Mockito.never;
1114
import static org.mockito.Mockito.verify;
1215
import static org.mockito.Mockito.when;
1316

@@ -207,4 +210,151 @@ void testUpdateApprovedMcpServerStringWithNullServers() throws Exception {
207210
Map<String, Object> resultServers = (Map<String, Object>) result.get("servers");
208211
assertTrue(resultServers.isEmpty(), "Servers map should be empty when MCP servers are null");
209212
}
213+
214+
@Test
215+
void testDetectChangesDropsApprovedServerWhenPluginUninstalled() throws Exception {
216+
// Regression test for https://github.com/microsoft/copilot-for-eclipse/issues/153 scenario 1:
217+
// a previously approved plugin is no longer providing any MCP server.
218+
IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class);
219+
when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore);
220+
221+
Map<String, Object> previouslyApprovedServers = new HashMap<>();
222+
previouslyApprovedServers.put("server-X", Map.of("url", "http://localhost:9000"));
223+
McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin",
224+
previouslyApprovedServers);
225+
Map<String, McpRegistrationInfo> persisted = new HashMap<>();
226+
persisted.put("com.example.plugin", persistedInfo);
227+
228+
// Live extension scan returned nothing (plugin uninstalled or no longer provides servers).
229+
setExtMcpInfoMap(new HashMap<>());
230+
231+
invokeDetectChanges(persisted);
232+
233+
String approvedServers = manager.getApprovedExtMcpServers();
234+
assertNotNull(approvedServers, "Approved servers JSON should be non-null after detect");
235+
Map<String, Object> result = gson.fromJson(approvedServers, Map.class);
236+
Map<String, Object> resultServers = (Map<String, Object>) result.get("servers");
237+
assertTrue(resultServers.isEmpty(),
238+
"Stale approved server must be dropped when the live extension scan returns nothing");
239+
240+
// No new approval prompt should be raised because there is no incoming registration to approve.
241+
verify(mockMcpConfigService, never()).setNewExtMcpRegFound(true);
242+
243+
// The verified state must be persisted so subsequent startups do not resurrect the stale entry.
244+
ArgumentCaptor<String> persistedJson = ArgumentCaptor.forClass(String.class);
245+
verify(mockPreferenceStore, atLeastOnce()).setValue(eq(Constants.MCP_EXTENSION_POINT_CONTRIB),
246+
persistedJson.capture());
247+
assertEquals("{}", persistedJson.getValue(),
248+
"Persisted contribution map should be empty after the plugin is gone");
249+
}
250+
251+
@Test
252+
void testDetectChangesDropsApprovalAndFlagsRedNoticeWhenConfigChanges() throws Exception {
253+
// Regression test for https://github.com/microsoft/copilot-for-eclipse/issues/153 scenario 2:
254+
// the contributing plugin returns a different config than what was previously approved.
255+
IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class);
256+
when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore);
257+
258+
Map<String, Object> oldServers = new HashMap<>();
259+
oldServers.put("server-X", Map.of("url", "http://localhost:9000"));
260+
McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", oldServers);
261+
Map<String, McpRegistrationInfo> persisted = new HashMap<>();
262+
persisted.put("com.example.plugin", persistedInfo);
263+
264+
// Live extension scan returned the same plugin but with a different server config (port change).
265+
Map<String, Object> newServers = new HashMap<>();
266+
newServers.put("server-X", Map.of("url", "http://localhost:9999"));
267+
McpRegistrationInfo currentInfo = createMcpRegistrationInfo(true, false, "Test Plugin", newServers);
268+
Map<String, McpRegistrationInfo> currentMap = new HashMap<>();
269+
currentMap.put("com.example.plugin", currentInfo);
270+
setExtMcpInfoMap(currentMap);
271+
272+
invokeDetectChanges(persisted);
273+
274+
String approvedServers = manager.getApprovedExtMcpServers();
275+
assertNotNull(approvedServers);
276+
Map<String, Object> result = gson.fromJson(approvedServers, Map.class);
277+
Map<String, Object> resultServers = (Map<String, Object>) result.get("servers");
278+
assertTrue(resultServers.isEmpty(),
279+
"Changed config must not be auto-applied; LSP must not receive the (now unapproved) entry");
280+
281+
assertFalse(currentInfo.isApproved(),
282+
"Previously approved entry whose config changed must be marked as unapproved until the user re-approves");
283+
verify(mockMcpConfigService).setNewExtMcpRegFound(true);
284+
verify(mockPreferenceStore, atLeastOnce()).setValue(eq(Constants.MCP_EXTENSION_POINT_CONTRIB),
285+
org.mockito.ArgumentMatchers.anyString());
286+
}
287+
288+
@Test
289+
void testDetectChangesPreservesApprovalWhenConfigUnchanged() throws Exception {
290+
// Companion test: when the live extension scan reports the same config as the persisted cache,
291+
// the previous approval must be carried over so the explicit LSP sync at the end of
292+
// doRegistration() can push the verified servers to the language server.
293+
IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class);
294+
when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore);
295+
296+
Map<String, Object> approvedServers = new HashMap<>();
297+
approvedServers.put("server-X", Map.of("url", "http://localhost:9000"));
298+
McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", approvedServers);
299+
Map<String, McpRegistrationInfo> persisted = new HashMap<>();
300+
persisted.put("com.example.plugin", persistedInfo);
301+
302+
// Live extension scan returned the same servers; default isApproved=false until carry-over runs.
303+
Map<String, Object> sameServers = new HashMap<>();
304+
sameServers.put("server-X", Map.of("url", "http://localhost:9000"));
305+
McpRegistrationInfo currentInfo = createMcpRegistrationInfo(true, false, "Test Plugin", sameServers);
306+
Map<String, McpRegistrationInfo> currentMap = new HashMap<>();
307+
currentMap.put("com.example.plugin", currentInfo);
308+
setExtMcpInfoMap(currentMap);
309+
310+
invokeDetectChanges(persisted);
311+
312+
assertTrue(currentInfo.isApproved(),
313+
"When the contributed config matches the persisted JSON, the previous approval must be carried over");
314+
315+
String approvedJson = manager.getApprovedExtMcpServers();
316+
assertNotNull(approvedJson);
317+
Map<String, Object> result = gson.fromJson(approvedJson, Map.class);
318+
Map<String, Object> resultServers = (Map<String, Object>) result.get("servers");
319+
assertEquals(1, resultServers.size(),
320+
"Carried-over approved server must be present in the approved servers JSON for the LSP sync");
321+
322+
// No red-notice should be raised because nothing has changed for the user to re-review.
323+
verify(mockMcpConfigService, never()).setNewExtMcpRegFound(true);
324+
}
325+
326+
/**
327+
* Reflectively replace the manager's private {@code extMcpInfoMap} so tests can simulate the
328+
* outcome of {@code loadMcpRegistrationExtensionPoint()} without requiring a live OSGi
329+
* extension registry.
330+
*/
331+
private void setExtMcpInfoMap(Map<String, McpRegistrationInfo> map) throws Exception {
332+
Field field = McpExtensionPointManager.class.getDeclaredField("extMcpInfoMap");
333+
field.setAccessible(true);
334+
field.set(manager, map);
335+
}
336+
337+
private void invokeDetectChanges(Map<String, McpRegistrationInfo> persisted) throws Exception {
338+
// Get the scanned map that was previously set via setExtMcpInfoMap
339+
Field field = McpExtensionPointManager.class.getDeclaredField("extMcpInfoMap");
340+
field.setAccessible(true);
341+
@SuppressWarnings("unchecked")
342+
Map<String, McpRegistrationInfo> scannedMap = (Map<String, McpRegistrationInfo>) field.get(manager);
343+
344+
// detectChangesInMcpContribs now takes (scannedMap, persistedMap) and no longer
345+
// calls updateApprovedMcpServerString / persistExtMcpInfo (those moved to doRegistration).
346+
Method detectMethod = McpExtensionPointManager.class.getDeclaredMethod("detectChangesInMcpContribs", Map.class,
347+
Map.class);
348+
detectMethod.setAccessible(true);
349+
detectMethod.invoke(manager, scannedMap, persisted);
350+
351+
// Mirror what doRegistration() does after detectChanges: swap the field and update/persist.
352+
field.set(manager, scannedMap);
353+
Method updateMethod = McpExtensionPointManager.class.getDeclaredMethod("updateApprovedMcpServerString", Map.class);
354+
updateMethod.setAccessible(true);
355+
updateMethod.invoke(manager, scannedMap);
356+
Method persistMethod = McpExtensionPointManager.class.getDeclaredMethod("persistExtMcpInfo", Map.class);
357+
persistMethod.setAccessible(true);
358+
persistMethod.invoke(manager, scannedMap);
359+
}
210360
}

com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,10 +1097,7 @@ private void showMcpToolContextMenu() {
10971097
approvalItem.addSelectionListener(new SelectionAdapter() {
10981098
@Override
10991099
public void widgetSelected(SelectionEvent e) {
1100-
String res = chatServiceManager.getMcpExtensionPointManager().approveExtMcpRegistration();
1101-
if (StringUtils.isNotBlank(res)) {
1102-
CopilotUi.getPlugin().getLanguageServerSettingManager().syncMcpRegistrationConfiguration();
1103-
}
1100+
chatServiceManager.getMcpExtensionPointManager().approveExtMcpRegistration();
11041101
}
11051102
});
11061103

0 commit comments

Comments
 (0)