diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/chat/ChatEventsManagerTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/chat/ChatEventsManagerTests.java new file mode 100644 index 000000000..dfa249242 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/chat/ChatEventsManagerTests.java @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.McpSamplingConfig; + +class ChatEventsManagerTests { + + private ChatEventsManager chatEventsManager; + + @BeforeEach + void setUp() { + chatEventsManager = new ChatEventsManager(); + } + + @Test + void getMcpSamplingConfig_returnsDefaultConfigWhenNoProviderRegistered() { + McpSamplingConfig config = chatEventsManager.getMcpSamplingConfig("test-server"); + + assertFalse(config.alwaysAllow()); + assertFalse(config.alwaysDeny()); + assertEquals(List.of(), config.allowedModels()); + } + + @Test + void getMcpSamplingConfig_delegatesToRegisteredProvider() { + McpSamplingConfigProvider provider = mock(McpSamplingConfigProvider.class); + McpSamplingConfig expected = new McpSamplingConfig(true, false, List.of()); + when(provider.getMcpSamplingConfig("test-server")).thenReturn(expected); + + chatEventsManager.registerMcpSamplingConfigProvider(provider); + + assertEquals(expected, chatEventsManager.getMcpSamplingConfig("test-server")); + } + + @Test + void getMcpSamplingConfig_fallsBackToDefaultAfterUnregister() { + McpSamplingConfigProvider provider = mock(McpSamplingConfigProvider.class); + chatEventsManager.registerMcpSamplingConfigProvider(provider); + chatEventsManager.unregisterMcpSamplingConfigProvider(provider); + + McpSamplingConfig config = chatEventsManager.getMcpSamplingConfig("test-server"); + + assertFalse(config.alwaysAllow()); + } +} diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java index a4d7fe1a8..d591e678a 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java @@ -13,6 +13,7 @@ import static org.mockito.Mockito.when; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -37,6 +38,7 @@ import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.FeatureFlags; +import com.microsoft.copilot.eclipse.core.chat.ChatEventsManager; import com.microsoft.copilot.eclipse.core.chat.service.IChatServiceManager; import com.microsoft.copilot.eclipse.core.chat.service.IReferencedFileService; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; @@ -44,6 +46,8 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationContextParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.CurrentEditorContext; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidChangeFeatureFlagsParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.McpSamplingConfig; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadMcpSamplingConfigParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.policy.DidChangePolicyParams; import com.microsoft.copilot.eclipse.core.utils.FileUtils; @@ -61,6 +65,9 @@ class CopilotLanguageClientTests { @Mock private IReferencedFileService fileService; + @Mock + private ChatEventsManager chatEventsManager; + @BeforeEach void setUp() { client = new CopilotLanguageClient(); @@ -145,6 +152,39 @@ void testOnDidChangeFeatureFlagsWithEmptyFeatureFlags() { } } + @Test + void testReadMcpSamplingConfig_requiresConfirmationAndAllowsAllModels() throws Exception { + McpSamplingConfig expected = new McpSamplingConfig(false, false, List.of()); + + try (MockedStatic copilotCoreMock = Mockito.mockStatic(CopilotCore.class)) { + copilotCoreMock.when(CopilotCore::getPlugin).thenReturn(plugin); + when(plugin.getChatEventsManager()).thenReturn(chatEventsManager); + when(chatEventsManager.getMcpSamplingConfig("test-server")).thenReturn(expected); + + Object[] result = client.readMcpSamplingConfig(new ReadMcpSamplingConfigParams("test-server")).get(); + + assertEquals(2, result.length); + assertEquals(expected, result[0]); + assertNull(result[1]); + } + } + + @Test + void testReadMcpSamplingConfig_reflectsPersistedAlwaysAllowDecision() throws Exception { + McpSamplingConfig approved = new McpSamplingConfig(true, false, List.of()); + + try (MockedStatic copilotCoreMock = Mockito.mockStatic(CopilotCore.class)) { + copilotCoreMock.when(CopilotCore::getPlugin).thenReturn(plugin); + when(plugin.getChatEventsManager()).thenReturn(chatEventsManager); + when(chatEventsManager.getMcpSamplingConfig("test-server")).thenReturn(approved); + + Object[] result = client.readMcpSamplingConfig(new ReadMcpSamplingConfigParams("test-server")).get(); + + assertEquals(approved, result[0]); + assertTrue(((McpSamplingConfig) result[0]).alwaysAllow()); + } + } + @Test void testOnDidChangePolicy_publishesAutoModelPolicyEventOnlyWhenValueChanges() throws InterruptedException { IEventBroker eventBroker = EclipseContextFactory diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/LsStreamConnectionProviderTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/LsStreamConnectionProviderTests.java index 9aca6a8fc..807e91144 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/LsStreamConnectionProviderTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/LsStreamConnectionProviderTests.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -27,6 +28,7 @@ void testInitializationOptions() { assertEquals(LsStreamConnectionProvider.EDITOR_NAME, options.getEditorInfo().getName()); assertEquals(LsStreamConnectionProvider.EDITOR_PLUGIN_NAME, options.getEditorPluginInfo().getName()); + assertTrue(options.getCopilotCapabilities().isMcpSampling()); } @Test diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java index 73817770d..518550ee9 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java @@ -59,6 +59,10 @@ private Constants() { public static final String AUTO_APPROVE_UNMATCHED_FILE_OP = "autoApproveUnmatchedFileOp"; public static final String AUTO_APPROVE_MCP_SERVERS = "autoApproveMcpServers"; public static final String AUTO_APPROVE_MCP_TOOLS = "autoApproveMcpTools"; + // Servers approved to skip the MCP sampling (inference) confirmation dialog. Kept separate from + // AUTO_APPROVE_MCP_SERVERS so that approving regular tool calls for a server never silently + // approves its (billable) sampling requests, and vice versa. + public static final String AUTO_APPROVE_MCP_SAMPLING_SERVERS = "autoApproveMcpSamplingServers"; public static final String AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS = "autoApproveTrustToolAnnotations"; public static final String AUTO_APPROVE_YOLO_MODE = "autoApproveYoloMode"; diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ChatEventsManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ChatEventsManager.java index dc156c455..d76589fe4 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ChatEventsManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ChatEventsManager.java @@ -4,6 +4,7 @@ package com.microsoft.copilot.eclipse.core.chat; import java.util.LinkedHashSet; +import java.util.List; import java.util.concurrent.CompletableFuture; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; @@ -11,6 +12,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.McpSamplingConfig; /** * Provider for chat progress. @@ -27,6 +29,11 @@ public class ChatEventsManager { */ public ToolInvocationListener agentToolListener; + /** + * Provider of persisted MCP sampling approval preferences. + */ + private McpSamplingConfigProvider mcpSamplingConfigProvider; + /** * Creates a new chat progress provider. */ @@ -101,4 +108,38 @@ public CompletableFuture invokeAgentTool(InvokeClient } return this.agentToolListener.onToolInvocation(params); } + + /** + * Registers the provider of persisted MCP sampling approval preferences. + * + * @param provider the provider to register + */ + public void registerMcpSamplingConfigProvider(McpSamplingConfigProvider provider) { + this.mcpSamplingConfigProvider = provider; + } + + /** + * Unregisters the MCP sampling config provider. + * + * @param provider the provider to unregister + */ + public void unregisterMcpSamplingConfigProvider(McpSamplingConfigProvider provider) { + if (this.mcpSamplingConfigProvider == provider) { + this.mcpSamplingConfigProvider = null; + } + } + + /** + * Reads the persisted sampling preferences for an MCP server. Returns a config with no + * auto-decision (requires confirmation, allows all models) when no provider is registered. + * + * @param serverName the MCP server name + * @return the sampling config for the server + */ + public McpSamplingConfig getMcpSamplingConfig(String serverName) { + if (this.mcpSamplingConfigProvider == null) { + return new McpSamplingConfig(false, false, List.of()); + } + return this.mcpSamplingConfigProvider.getMcpSamplingConfig(serverName); + } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java index 07f10d92a..c7729a0f7 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java @@ -18,6 +18,12 @@ public class ConfirmationAction { /** Metadata key for the action type enum name. */ public static final String META_ACTION = "action"; + /** Metadata key for actions handled entirely by the confirmation UI. */ + public static final String META_UI_ACTION = "uiAction"; + + /** UI action that reveals the prompt associated with a sampling request. */ + public static final String UI_ACTION_REVIEW_PROMPT = "reviewPrompt"; + private final String label; private final boolean accept; private final ConfirmationActionScope scope; @@ -74,6 +80,12 @@ public static ConfirmationAction skip(String label) { return new ConfirmationAction(label, false, null, null, false); } + /** Creates an action that reveals the sampling prompt without resolving the confirmation. */ + public static ConfirmationAction reviewPrompt(String label) { + return new ConfirmationAction(label, false, null, + Map.of(META_UI_ACTION, UI_ACTION_REVIEW_PROMPT), false); + } + @Override public int hashCode() { return Objects.hash(accept, label, metadata, primary, scope); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/McpSamplingConfigProvider.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/McpSamplingConfigProvider.java new file mode 100644 index 000000000..7f1f92d00 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/McpSamplingConfigProvider.java @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.McpSamplingConfig; + +/** + * Provides the persisted MCP sampling (inference) approval preferences for a server, so that the + * {@code copilot/readMcpSamplingConfig} language server request reflects the user's actual + * previously-cached decisions instead of always requiring re-confirmation. + */ +public interface McpSamplingConfigProvider { + + /** + * Reads the sampling preferences for the given MCP server. + * + * @param serverName the MCP server name, may be {@code null} + * @return the persisted sampling config for the server + */ + McpSamplingConfig getMcpSamplingConfig(String serverName); +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java index 07afcc18f..1d7bf2b35 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java @@ -57,10 +57,12 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.core.lsp.protocol.McpSamplingConfig; import com.microsoft.copilot.eclipse.core.lsp.protocol.OnChangeMcpServerToolsParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.RateLimitWarningParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadDirectoryResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadFileResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadMcpSamplingConfigParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.policy.DidChangePolicyParams; @@ -181,6 +183,16 @@ public CompletableFuture confirmClientTool(InvokeClientToolConfirmatio }); } + /** + * Read the sampling preferences for an MCP server. + */ + @JsonRequest("copilot/readMcpSamplingConfig") + public CompletableFuture readMcpSamplingConfig(ReadMcpSamplingConfigParams params) { + McpSamplingConfig config = CopilotCore.getPlugin().getChatEventsManager() + .getMcpSamplingConfig(params.serverName()); + return CompletableFuture.completedFuture(new Object[] { config, null }); + } + @Override public CompletableFuture> configuration(ConfigurationParams params) { return CompletableFuture.supplyAsync(() -> { diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotCapabilities.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotCapabilities.java index 14b740c44..3bf882934 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotCapabilities.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotCapabilities.java @@ -32,6 +32,10 @@ public class CopilotCapabilities { private boolean manageTodoListTool; + // Always advertised as supported; Eclipse always implements the client side of MCP sampling + // (the confirmation dialog and readMcpSamplingConfig handler), so this is not user-configurable. + private final boolean mcpSampling = true; + private List contentProvider; /** @@ -112,6 +116,10 @@ public void setManageTodoListTool(boolean manageTodoListTool) { this.manageTodoListTool = manageTodoListTool; } + public boolean isMcpSampling() { + return mcpSampling; + } + @Override public String toString() { ToStringBuilder builder = new ToStringBuilder(this); @@ -124,13 +132,14 @@ public String toString() { builder.append("contentProvider", contentProvider); builder.append("debuggerAgent", debuggerAgent); builder.append("manageTodoListTool", manageTodoListTool); + builder.append("mcpSampling", mcpSampling); return builder.toString(); } @Override public int hashCode() { return Objects.hash(cveRemediatorAgent, debuggerAgent, didChangeFeatureFlags, fetch, manageTodoListTool, - stateDatabase, subAgent, watchedFiles, contentProvider); + mcpSampling, stateDatabase, subAgent, watchedFiles, contentProvider); } @Override @@ -145,8 +154,8 @@ public boolean equals(Object obj) { return cveRemediatorAgent == other.cveRemediatorAgent && debuggerAgent == other.debuggerAgent && didChangeFeatureFlags == other.didChangeFeatureFlags && fetch == other.fetch - && manageTodoListTool == other.manageTodoListTool && stateDatabase == other.stateDatabase - && subAgent == other.subAgent && watchedFiles == other.watchedFiles + && manageTodoListTool == other.manageTodoListTool && mcpSampling == other.mcpSampling + && stateDatabase == other.stateDatabase && subAgent == other.subAgent && watchedFiles == other.watchedFiles && Objects.equals(contentProvider, other.contentProvider); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/McpSamplingConfig.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/McpSamplingConfig.java new file mode 100644 index 000000000..8b799df7a --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/McpSamplingConfig.java @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; + +/** + * User preferences for MCP sampling requests from a server. + */ +public record McpSamplingConfig(boolean alwaysAllow, boolean alwaysDeny, List allowedModels) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ReadMcpSamplingConfigParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ReadMcpSamplingConfigParams.java new file mode 100644 index 000000000..159f4b935 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ReadMcpSamplingConfigParams.java @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Parameters for reading MCP sampling preferences. + */ +public record ReadMcpSamplingConfigParams(String serverName) { +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java index 4380439b5..fc4727022 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java @@ -31,6 +31,7 @@ import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolAnnotations; +import com.microsoft.copilot.eclipse.ui.chat.Messages; @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -398,6 +399,110 @@ void buildContent_noActionsWhenServerAndToolNull() { McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)); } + @Test + void buildContent_samplingHasInferenceApprovalActions() { + ConfirmationResult result = evaluate( + buildSamplingParams(SERVER), CONV_ID); + + ConfirmationContent content = result.getContent(); + List actions = content.getActions(); + assertEquals(Messages.confirmation_sampling_title, + content.getTitle()); + assertEquals(4, actions.size()); + assertEquals(Messages.confirmation_sampling_action_yes, + actions.get(0).getLabel()); + assertEquals(ConfirmationActionScope.ONCE, + actions.get(0).getScope()); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SAMPLING_SERVER_GLOBAL)); + assertTrue(actions.stream().anyMatch(action -> + ConfirmationAction.UI_ACTION_REVIEW_PROMPT.equals( + action.getMetadata().get( + ConfirmationAction.META_UI_ACTION)))); + assertEquals(Messages.confirmation_sampling_action_no, + actions.get(actions.size() - 1).getLabel()); + } + + @Test + void buildContent_samplingWithoutAutoApprovalOmitsPersistentAction() { + ConfirmationResult result = handler.evaluate( + buildSamplingParams(SERVER), CONV_ID, false); + + List actions = + result.getContent().getActions(); + assertEquals(3, actions.size()); + assertFalse(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SAMPLING_SERVER_GLOBAL)); + } + + // --- sampling / regular-tool approval isolation --- + + @Test + void evaluate_samplingNotAutoApprovedWhenOnlyRegularToolServerApproved() { + // Approving regular tool calls for a server must NOT silently auto-approve + // its (billable) sampling requests. + stubGlobalServers(List.of(SERVER)); + stubSamplingServers(List.of()); + + ConfirmationResult result = evaluate( + buildSamplingParams(SERVER), CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_regularToolNotAutoApprovedWhenOnlySamplingServerApproved() { + // Approving sampling for a server must NOT silently auto-approve its + // regular tool calls. + stubSamplingServers(List.of(SERVER)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_samplingAutoApprovedWhenSamplingServerGloballyApproved() { + stubSamplingServers(List.of(SERVER)); + + ConfirmationResult result = evaluate( + buildSamplingParams(SERVER), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void cacheDecision_acceptSamplingServerGlobal_writesToDedicatedPreferenceKey() { + stubSamplingServers(List.of()); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_SAMPLING_SERVER_GLOBAL, + Map.of(McpConfirmationHandler.META_SERVER_NAME, SERVER)); + handler.cacheDecision(action, buildSamplingParams(SERVER), CONV_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq( + Constants.AUTO_APPROVE_MCP_SAMPLING_SERVERS), + captor.capture()); + assertTrue(captor.getValue().contains(SERVER)); + } + + @Test + void getMcpSamplingConfig_alwaysAllowFalseByDefault() { + stubSamplingServers(List.of()); + + assertFalse(handler.getMcpSamplingConfig(SERVER).alwaysAllow()); + } + + @Test + void getMcpSamplingConfig_alwaysAllowTrueAfterServerApproved() { + stubSamplingServers(List.of(SERVER)); + + assertTrue(handler.getMcpSamplingConfig(SERVER).alwaysAllow()); + } + // --- Helpers --- private void stubGlobalServers(List servers) { @@ -405,6 +510,12 @@ private void stubGlobalServers(List servers) { .thenReturn(GSON.toJson(servers)); } + private void stubSamplingServers(List servers) { + when(preferenceStore.getString( + Constants.AUTO_APPROVE_MCP_SAMPLING_SERVERS)) + .thenReturn(GSON.toJson(servers)); + } + private void stubGlobalTools(List tools) { when(preferenceStore.getString(Constants.AUTO_APPROVE_MCP_TOOLS)) .thenReturn(GSON.toJson(tools)); @@ -438,6 +549,19 @@ private static InvokeClientToolConfirmationParams buildParams( return params; } + private static InvokeClientToolConfirmationParams buildSamplingParams( + String serverName) { + InvokeClientToolConfirmationParams params = + buildParams(serverName, null); + @SuppressWarnings("unchecked") + Map input = + (Map) params.getInput(); + input.put("mcpType", "sampling"); + input.put("content", Map.of("type", "text", + "text", "Prompt to review")); + return params; + } + private static ConfirmationAction buildAction( McpConfirmationHandler.Action type, Map extra) { Map meta = new java.util.HashMap<>(extra); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java index 1f65aead9..97ec6355e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java @@ -27,6 +27,7 @@ import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Menu; import org.eclipse.swt.widgets.MenuItem; +import org.eclipse.swt.widgets.Text; import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; @@ -68,6 +69,7 @@ public class InvokeToolConfirmationDialog extends Composite { private Runnable titleFontChangeCallback; private ConfirmationContent confirmationContent; private ConfirmationAction selectedAction; + private Text samplingPromptText; /** * Create a new confirmation dialog driven by {@link ConfirmationContent}. @@ -160,11 +162,15 @@ private void createDialogContent(String title, String message, @SuppressWarnings("unchecked") private void createInputContent(Object input) { - if (input == null) { + if (!(input instanceof Map)) { return; } Map inputMap = (Map) input; + if ("sampling".equals(inputMap.get("mcpType"))) { + createSamplingPromptReview(inputMap); + } + if (inputMap.containsKey(ACTION_KEY)) { createScrollableCommand(formatDebuggerInput(inputMap), SWT.H_SCROLL | SWT.V_SCROLL); @@ -182,6 +188,33 @@ private void createInputContent(Object input) { } } + private void createSamplingPromptReview(Map inputMap) { + String prompt = extractSamplingPrompt(inputMap.get("content")); + if (StringUtils.isBlank(prompt)) { + return; + } + samplingPromptText = new Text(this, + SWT.BORDER | SWT.MULTI | SWT.READ_ONLY | SWT.WRAP | SWT.V_SCROLL); + samplingPromptText.setText(prompt); + GridData data = new GridData(SWT.FILL, SWT.FILL, true, false); + data.heightHint = 180; + data.exclude = true; + samplingPromptText.setLayoutData(data); + samplingPromptText.setVisible(false); + registerControlForFontUpdates(samplingPromptText); + } + + private String extractSamplingPrompt(Object content) { + if (content instanceof Map contentMap) { + Object text = contentMap.get("text"); + return text instanceof String ? (String) text : null; + } + if (content instanceof List contentList && !contentList.isEmpty()) { + return extractSamplingPrompt(contentList.get(0)); + } + return content instanceof String ? (String) content : null; + } + private void createScrollableCommand(String text, int scrollStyle) { ScrolledComposite commandScroll = new ScrolledComposite(this, scrollStyle); @@ -218,10 +251,13 @@ private void createActionButtons() { ConfirmationAction primaryAction = null; ConfirmationAction dismissAction = null; + ConfirmationAction reviewAction = null; List dropdownActions = new ArrayList<>(); for (ConfirmationAction action : actions) { - if (!action.isAccept()) { + if (isReviewPromptAction(action)) { + reviewAction = action; + } else if (!action.isAccept()) { dismissAction = action; } else if (action.isPrimary()) { primaryAction = action; @@ -234,8 +270,8 @@ private void createActionButtons() { return; } - // Column count: primary dropdown button + dismiss - Composite actionArea = newButtonArea(2); + int columnCount = reviewAction != null ? 3 : 2; + Composite actionArea = newButtonArea(columnCount); // --- primary dropdown button --- SplitDropdownButton primaryDropdown = @@ -277,6 +313,15 @@ public void widgetSelected(SelectionEvent e) { } }); + if (reviewAction != null) { + Button reviewBtn = new Button(actionArea, SWT.PUSH); + reviewBtn.setLayoutData( + new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); + reviewBtn.setText(reviewAction.getLabel()); + registerControlForFontUpdates(reviewBtn); + reviewBtn.addListener(SWT.Selection, e -> toggleSamplingPrompt()); + } + // --- dismiss (skip) button --- Button dismissBtn = new Button(actionArea, SWT.PUSH); dismissBtn.setLayoutData( @@ -293,6 +338,23 @@ public void widgetSelected(SelectionEvent e) { }); } + private boolean isReviewPromptAction(ConfirmationAction action) { + return ConfirmationAction.UI_ACTION_REVIEW_PROMPT.equals( + action.getMetadata().get(ConfirmationAction.META_UI_ACTION)); + } + + private void toggleSamplingPrompt() { + if (samplingPromptText == null || samplingPromptText.isDisposed()) { + return; + } + GridData data = (GridData) samplingPromptText.getLayoutData(); + boolean show = !samplingPromptText.isVisible(); + data.exclude = !show; + samplingPromptText.setVisible(show); + requestLayout(); + getParent().requestLayout(); + } + // --------------- helpers --------------- private Composite newButtonArea(int columns) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java index bb946f7a3..e68f6d551 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java @@ -67,6 +67,13 @@ public final class Messages extends NLS { public static String confirmation_title_mcpToolDefault; public static String confirmation_action_allowServerSession; public static String confirmation_action_alwaysAllowServer; + public static String confirmation_sampling_title; + public static String confirmation_sampling_message; + public static String confirmation_sampling_action_yes; + public static String confirmation_sampling_action_alwaysAllow; + public static String confirmation_sampling_action_reviewPrompt; + public static String confirmation_sampling_action_no; + public static String confirmation_sampling_unknownServer; // Confirmation dialog titles public static String confirmation_title_terminal; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java index 50242adc6..83b300965 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java @@ -15,6 +15,7 @@ import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.McpSamplingConfig; /** * Central entry point for auto-approve evaluation. Classifies each tool confirmation request @@ -64,6 +65,7 @@ public static ToolCategory fromValue(String value) { private final ConfirmationHandler fallbackHandler = new FallbackConfirmationHandler(); private final IPreferenceStore preferenceStore; + private final McpConfirmationHandler mcpConfirmationHandler; /** * Creates a new ConfirmationService. @@ -82,8 +84,8 @@ public ConfirmationService(IPreferenceStore preferenceStore, handlers.put(ToolCategory.FILE_READ, fileHandler); handlers.put(ToolCategory.FILE_WRITE, fileHandler); handlers.put(ToolCategory.FILE_OPERATION, fileHandler); - handlers.put(ToolCategory.MCP_TOOL, - new McpConfirmationHandler(preferenceStore)); + this.mcpConfirmationHandler = new McpConfirmationHandler(preferenceStore); + handlers.put(ToolCategory.MCP_TOOL, mcpConfirmationHandler); } /** @@ -143,6 +145,16 @@ public void clearSession(String conversationId) { } } + /** + * Reads the persisted MCP sampling approval preferences for a server. + * + * @param serverName the MCP server name + * @return the sampling config for the server + */ + public McpSamplingConfig getMcpSamplingConfig(String serverName) { + return mcpConfirmationHandler.getMcpSamplingConfig(serverName); + } + ToolCategory classify(InvokeClientToolConfirmationParams params) { return ToolCategory.fromValue(ConfirmationHandler.extractToolType(params)); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java index c27572235..7a1559dbc 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java @@ -27,6 +27,7 @@ import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.McpSamplingConfig; import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolAnnotations; import com.microsoft.copilot.eclipse.ui.chat.Messages; @@ -46,7 +47,9 @@ public enum Action { /** Allow all tools from a server for the current session/conversation. */ ACCEPT_SERVER_SESSION, /** Always allow all tools from a server (persisted globally). */ - ACCEPT_SERVER_GLOBAL + ACCEPT_SERVER_GLOBAL, + /** Always allow MCP sampling (inference) requests from a server (persisted globally). */ + ACCEPT_SAMPLING_SERVER_GLOBAL } static final String META_SERVER_NAME = "serverName"; @@ -89,12 +92,14 @@ public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, /** * Evaluates an MCP tool confirmation request. Check order: - * 1. Session approved servers (by conversationId) - * 2. Session approved tools (by conversationId, key = "server::tool") - * 3. Global approved servers list - * 4. Global approved tools list - * 5. Trust annotations (readOnlyHint=true AND openWorldHint=false) - * 6. Otherwise: needs confirmation + * 1. Sampling requests: checked against their own dedicated approval list, never the + * regular tool/server lists below (sampling is a distinct, billable permission) + * 2. Session approved servers (by conversationId) + * 3. Session approved tools (by conversationId, key = "server::tool") + * 4. Global approved servers list + * 5. Global approved tools list + * 6. Trust annotations (readOnlyHint=true AND openWorldHint=false) + * 7. Otherwise: needs confirmation */ private ConfirmationResult evaluateAutoApprovalEnabled( InvokeClientToolConfirmationParams params, @@ -105,7 +110,18 @@ private ConfirmationResult evaluateAutoApprovalEnabled( ? serverName.toLowerCase(Locale.ROOT) : null; String toolKey = buildToolKey(serverLower, toolName); - // 1. Session: server approved for this conversation + // 1. Sampling requests are evaluated independently of the regular tool/server approval + // lists below, since auto-approving tool calls for a server should never silently + // auto-approve its (billable) sampling/inference requests, or vice versa. + if (isSamplingRequest(params)) { + if (serverLower != null && isServerApprovedForSampling(serverLower)) { + return ConfirmationResult.AUTO_APPROVED; + } + return ConfirmationResult.needsConfirmation( + buildSamplingContent(serverName, false)); + } + + // 2. Session: server approved for this conversation if (serverLower != null) { Set sessionServers = approvedServers.get(sessionConversationId); @@ -124,7 +140,7 @@ private ConfirmationResult evaluateAutoApprovalEnabled( } } - // 3. Global: server in approved servers list + // 4. Global: server in approved servers list if (serverLower != null) { List globalServers = loadJsonList( Constants.AUTO_APPROVE_MCP_SERVERS); @@ -135,7 +151,7 @@ private ConfirmationResult evaluateAutoApprovalEnabled( } } - // 4. Global: tool in approved tools list + // 5. Global: tool in approved tools list if (toolKey != null) { List globalTools = loadJsonList( Constants.AUTO_APPROVE_MCP_TOOLS); @@ -146,7 +162,7 @@ private ConfirmationResult evaluateAutoApprovalEnabled( } } - // 5. Trust annotations: read-only and not open-world + // 6. Trust annotations: read-only and not open-world if (preferenceStore.getBoolean( Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)) { ToolAnnotations annotations = params.getAnnotations(); @@ -157,7 +173,7 @@ private ConfirmationResult evaluateAutoApprovalEnabled( } } - // 6. Needs confirmation + // 7. Needs confirmation return ConfirmationResult.needsConfirmation( buildContent(params, serverName, toolName)); } @@ -166,10 +182,37 @@ private ConfirmationResult evaluateAutoApprovalDisabled( InvokeClientToolConfirmationParams params) { String serverName = extractServerName(params); String toolName = extractToolName(params); + if (isSamplingRequest(params)) { + return ConfirmationResult.needsConfirmation( + buildSamplingContent(serverName, true)); + } return ConfirmationResult.needsConfirmation( buildContent(params, serverName, toolName, /* simplifiedOnly= */ true)); } + private ConfirmationContent buildSamplingContent( + String serverName, boolean simplifiedOnly) { + final String displayName = StringUtils.defaultIfBlank(serverName, + Messages.confirmation_sampling_unknownServer); + List actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce( + Messages.confirmation_sampling_action_yes)); + if (!simplifiedOnly && serverName != null) { + actions.add(action(Action.ACCEPT_SAMPLING_SERVER_GLOBAL, + Messages.confirmation_sampling_action_alwaysAllow, + ConfirmationActionScope.GLOBAL, + Map.of(META_SERVER_NAME, serverName))); + } + actions.add(ConfirmationAction.reviewPrompt( + Messages.confirmation_sampling_action_reviewPrompt)); + actions.add(ConfirmationAction.skip( + Messages.confirmation_sampling_action_no)); + return new ConfirmationContent( + Messages.confirmation_sampling_title, + NLS.bind(Messages.confirmation_sampling_message, displayName), + actions); + } + @Override public void cacheDecision(ConfirmationAction confirmAction, InvokeClientToolConfirmationParams params, @@ -223,6 +266,11 @@ public void cacheDecision(ConfirmationAction confirmAction, addToGlobalList(Constants.AUTO_APPROVE_MCP_SERVERS, serverName); } break; + case ACCEPT_SAMPLING_SERVER_GLOBAL: + if (serverName != null) { + addToGlobalList(Constants.AUTO_APPROVE_MCP_SAMPLING_SERVERS, serverName); + } + break; default: break; } @@ -326,6 +374,42 @@ private String extractToolName( return null; } + private boolean isSamplingRequest( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map inputMap) { + return "sampling".equals(inputMap.get("mcpType")); + } + return false; + } + + private boolean isServerApprovedForSampling(String serverLower) { + List approvedSamplingServers = loadJsonList( + Constants.AUTO_APPROVE_MCP_SAMPLING_SERVERS); + for (String s : approvedSamplingServers) { + if (s.toLowerCase(Locale.ROOT).equals(serverLower)) { + return true; + } + } + return false; + } + + /** + * Reads the persisted sampling preferences for the given MCP server, reflecting any + * "don't ask again" decision the user previously made for sampling requests from that server. + * + * @param serverName the MCP server name + * @return the sampling config: {@code alwaysAllow} is true only if the server was previously + * approved via the sampling-specific "don't ask again" action; deny decisions and + * per-model restrictions are not yet supported by this dialog, so those fields are always + * {@code false}/empty. + */ + public McpSamplingConfig getMcpSamplingConfig(String serverName) { + boolean alwaysAllow = StringUtils.isNotBlank(serverName) + && isServerApprovedForSampling(serverName.toLowerCase(Locale.ROOT)); + return new McpSamplingConfig(alwaysAllow, false, List.of()); + } + private static String buildToolKey(String serverLower, String toolName) { if (serverLower == null || toolName == null) { return null; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties index 671b22f88..3cd6621d7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties @@ -62,6 +62,13 @@ confirmation_title_mcpTool=Run ''{0}'' tool from ''{1}'' MCP server confirmation_title_mcpToolDefault=Allow MCP tool? confirmation_action_allowServerSession=Allow tools from {0} in this Session confirmation_action_alwaysAllowServer=Always Allow tools from {0} +confirmation_sampling_title=Approve inference request? +confirmation_sampling_message=MCP server "{0}" is requesting to perform an inference request. Approving it allows the server to use the provided messages and prompt and consumes Copilot plan requests. +confirmation_sampling_action_yes=Yes +confirmation_sampling_action_alwaysAllow=Yes, and don't ask again for this server +confirmation_sampling_action_reviewPrompt=Review Prompt +confirmation_sampling_action_no=No +confirmation_sampling_unknownServer=unknown MCP server # Confirmation dialog titles confirmation_title_terminal=Run command in terminal diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java index c7a013c2a..697840ecf 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java @@ -21,6 +21,7 @@ import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.McpSamplingConfigProvider; import com.microsoft.copilot.eclipse.core.chat.ToolInvocationListener; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; @@ -30,6 +31,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.core.lsp.protocol.McpSamplingConfig; import com.microsoft.copilot.eclipse.core.lsp.protocol.RegisterToolsParams; import com.microsoft.copilot.eclipse.core.utils.JdtUtils; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; @@ -55,7 +57,8 @@ /** * Service to manage and access tools. */ -public class AgentToolService implements ToolInvocationListener, TerminalServiceManager.TerminalServiceListener { +public class AgentToolService implements ToolInvocationListener, McpSamplingConfigProvider, + TerminalServiceManager.TerminalServiceListener { private ConcurrentHashMap tools; private ChatView boundChatView; @@ -122,6 +125,7 @@ private void registerDefaultTools() { ChatEventsManager chatEventsManager = CopilotCore.getPlugin().getChatEventsManager(); if (chatEventsManager != null) { chatEventsManager.registerAgentToolListener(this); + chatEventsManager.registerMcpSamplingConfigProvider(this); } } @@ -345,6 +349,11 @@ public ConfirmationService getConfirmationService() { return confirmationService; } + @Override + public McpSamplingConfig getMcpSamplingConfig(String serverName) { + return confirmationService.getMcpSamplingConfig(serverName); + } + /** Returns the registry of user-attached context files. */ public AttachedFileRegistry getAttachedFileRegistry() { return attachedFileRegistry; @@ -360,6 +369,11 @@ public void dispose() { terminalManager.removeListener(this); } + ChatEventsManager chatEventsManager = CopilotCore.getPlugin().getChatEventsManager(); + if (chatEventsManager != null) { + chatEventsManager.unregisterMcpSamplingConfigProvider(this); + } + this.tools.clear(); unbindChatView(); }