Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,13 +38,16 @@

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;
import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCapabilities;
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;

Expand All @@ -61,6 +65,9 @@ class CopilotLanguageClientTests {
@Mock
private IReferencedFileService fileService;

@Mock
private ChatEventsManager chatEventsManager;

@BeforeEach
void setUp() {
client = new CopilotLanguageClient();
Expand Down Expand Up @@ -145,6 +152,39 @@ void testOnDidChangeFeatureFlagsWithEmptyFeatureFlags() {
}
}

@Test
void testReadMcpSamplingConfig_requiresConfirmationAndAllowsAllModels() throws Exception {
McpSamplingConfig expected = new McpSamplingConfig(false, false, List.of());

try (MockedStatic<CopilotCore> 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<CopilotCore> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
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;
import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams;
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.
Expand All @@ -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.
*/
Expand Down Expand Up @@ -101,4 +108,38 @@ public CompletableFuture<LanguageModelToolResult[]> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -181,6 +183,16 @@ public CompletableFuture<Object[]> confirmClientTool(InvokeClientToolConfirmatio
});
}

/**
* Read the sampling preferences for an MCP server.
*/
@JsonRequest("copilot/readMcpSamplingConfig")
public CompletableFuture<Object[]> readMcpSamplingConfig(ReadMcpSamplingConfigParams params) {
McpSamplingConfig config = CopilotCore.getPlugin().getChatEventsManager()
.getMcpSamplingConfig(params.serverName());
return CompletableFuture.completedFuture(new Object[] { config, null });
}
Comment on lines +186 to +194

@Override
public CompletableFuture<List<Object>> configuration(ConfigurationParams params) {
return CompletableFuture.supplyAsync(() -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> contentProvider;

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> allowedModels) {
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Loading