diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotLanguageServerSettingsTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotLanguageServerSettingsTests.java new file mode 100644 index 00000000..97497cb0 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotLanguageServerSettingsTests.java @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +/** + * Tests for MCP server merge behavior in CopilotLanguageServerSettings. + */ +class CopilotLanguageServerSettingsTests { + + private static final String SERVER_A_JSON = "{\"servers\": {\"server-a\": {\"command\": \"echo\"}}}"; + private static final String KEEP_ME_JSON = "{\"servers\": {\"keep-me\": {\"command\": \"stay\"}}}"; + + private CopilotLanguageServerSettings settings; + private Gson gson; + + @BeforeEach + void setUp() { + settings = new CopilotLanguageServerSettings(); + gson = new GsonBuilder().disableHtmlEscaping().create(); + } + + // --- setMcpServers --- + + @Test + void setMcpServers_withWrappedFormat_extractsServers() { + settings.setMcpServers(SERVER_A_JSON); + + String result = getMcpServers(); + assertNotNull(result); + Map servers = parseJsonMap(result); + assertEquals(1, servers.size()); + assertTrue(servers.containsKey("server-a")); + } + + @Test + void setMcpServers_withBlankInput_doesNotThrow() { + settings.setMcpServers(""); + // blank input is passed through as-is by parseMcpServers + String result = getMcpServers(); + assertEquals("", result); + } + + @Test + void setMcpServers_withInvalidJson_setsNull() { + settings.setMcpServers("not valid json"); + String result = getMcpServers(); + assertNull(result); + } + + // --- addMcpServers --- + + @Test + void addMcpServers_withNoExistingServers_setsServers() { + String json = "{\"servers\": {\"new-server\": {\"type\": \"stdio\", \"command\": \"npx\"}}}"; + settings.addMcpServers(json); + + String result = getMcpServers(); + assertNotNull(result); + Map servers = parseJsonMap(result); + assertEquals(1, servers.size()); + assertTrue(servers.containsKey("new-server")); + } + + @Test + void addMcpServers_withExistingServers_mergesBothServers() { + settings.setMcpServers("{\"servers\": {\"existing-server\": {\"command\": \"a\"}}}"); + + settings.addMcpServers("{\"servers\": {\"added-server\": {\"command\": \"b\"}}}"); + + String result = getMcpServers(); + assertNotNull(result); + Map servers = parseJsonMap(result); + assertEquals(2, servers.size()); + assertTrue(servers.containsKey("existing-server")); + assertTrue(servers.containsKey("added-server")); + } + + @Test + void addMcpServers_withOverlappingName_laterOverridesEarlier() { + settings.setMcpServers("{\"servers\": {\"shared\": {\"command\": \"old-cmd\"}}}"); + settings.addMcpServers("{\"servers\": {\"shared\": {\"command\": \"new-cmd\"}}}"); + + String result = getMcpServers(); + assertNotNull(result); + assertTrue(result.contains("new-cmd")); + } + + @Test + void addMcpServers_withBlankInput_noChange() { + settings.setMcpServers(KEEP_ME_JSON); + settings.addMcpServers(""); + + String result = getMcpServers(); + assertNotNull(result); + Map servers = parseJsonMap(result); + assertEquals(1, servers.size()); + assertTrue(servers.containsKey("keep-me")); + } + + @Test + void addMcpServers_withNullInput_noChange() { + settings.setMcpServers(KEEP_ME_JSON); + + settings.addMcpServers(null); + + String result = getMcpServers(); + assertNotNull(result); + Map servers = parseJsonMap(result); + assertEquals(1, servers.size()); + assertTrue(servers.containsKey("keep-me")); + } + + @Test + void addMcpServers_withInvalidJson_noChange() { + settings.setMcpServers(KEEP_ME_JSON); + + settings.addMcpServers("not valid json"); + + String result = getMcpServers(); + assertNotNull(result); + Map servers = parseJsonMap(result); + assertEquals(1, servers.size()); + assertTrue(servers.containsKey("keep-me")); + } + + // --- full merge pipeline: setMcpServers (file-based) + addMcpServers (preferences) + addMcpServers (ext) --- + + @Test + void fullMergePipeline_filesThenPrefsThenExtPoints() { + // Step 1: file-based servers (lowest priority) via setMcpServers + settings.setMcpServers( + "{\"servers\": {\"file-only\": {\"command\": \"file-cmd\"}, " + "\"shared\": {\"command\": \"file-shared\"}}}"); + + // Step 2: preferences (higher priority) via addMcpServers + settings.addMcpServers( + "{\"servers\": {\"pref-only\": {\"command\": \"pref-cmd\"}, " + "\"shared\": {\"command\": \"pref-shared\"}}}"); + + // Step 3: extension points (highest priority) via addMcpServers + settings.addMcpServers( + "{\"servers\": {\"ext-only\": {\"command\": \"ext-cmd\"}, " + "\"shared\": {\"command\": \"ext-shared\"}}}"); + + String result = getMcpServers(); + assertNotNull(result); + Map servers = parseJsonMap(result); + + // All unique servers present + assertEquals(4, servers.size()); + assertTrue(servers.containsKey("file-only")); + assertTrue(servers.containsKey("pref-only")); + assertTrue(servers.containsKey("ext-only")); + + // "shared" should have the highest-priority (ext) value + assertTrue(servers.containsKey("shared")); + assertTrue(result.contains("ext-shared")); + } + + @Test + void addMcpServers_withMultipleCalls_serversAccumulate() { + settings.addMcpServers("{\"servers\": {\"first\": {\"command\": \"1\"}}}"); + settings.addMcpServers("{\"servers\": {\"second\": {\"command\": \"2\"}}}"); + settings.addMcpServers("{\"servers\": {\"third\": {\"command\": \"3\"}}}"); + + String result = getMcpServers(); + assertNotNull(result); + Map servers = parseJsonMap(result); + assertEquals(3, servers.size()); + assertTrue(servers.containsKey("first")); + assertTrue(servers.containsKey("second")); + assertTrue(servers.containsKey("third")); + } + + private String getMcpServers() { + return settings.getGithubSettings().getCopilotSettings().getMcpServers(); + } + + private Map parseJsonMap(String json) { + return gson.fromJson(json, new TypeToken>() { + }.getType()); + } +} diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/McpFileConfigServiceTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/McpFileConfigServiceTests.java new file mode 100644 index 00000000..28e5ede2 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/McpFileConfigServiceTests.java @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class McpFileConfigServiceTests { + + @TempDir + Path tempDir; + + // --- parseServers --- + + @Test + void parseServers_withServersWrapper_extractsServers() { + String json = """ + { + "servers": { + "my-server": { + "type": "stdio", + "command": "npx", + "args": ["-y", "mcp-server"] + } + } + } + """; + Map servers = McpFileConfigService.parseServers(json, "test"); + assertNotNull(servers); + assertEquals(1, servers.size()); + assertTrue(servers.containsKey("my-server")); + } + + @Test + void parseServers_withoutServersWrapper_treatsWholeObjectAsServers() { + String json = """ + { + "my-server": { + "type": "stdio", + "command": "npx" + } + } + """; + Map servers = McpFileConfigService.parseServers(json, "test"); + assertNotNull(servers); + assertEquals(1, servers.size()); + assertTrue(servers.containsKey("my-server")); + } + + @Test + void parseServers_filtersOutInputsKey() { + String json = """ + { + "inputs": [{"type": "promptString"}], + "servers": { + "fetch": { + "command": "uvx", + "args": ["mcp-server-fetch"] + } + } + } + """; + Map servers = McpFileConfigService.parseServers(json, "test"); + assertNotNull(servers); + assertEquals(1, servers.size()); + assertTrue(servers.containsKey("fetch")); + } + + @Test + void parseServers_emptyJson_returnsNull() { + Map servers = McpFileConfigService.parseServers("{}", "test"); + assertNull(servers); + } + + @Test + void parseServers_invalidJson_returnsNull() { + Map servers = McpFileConfigService.parseServers("not json", "test"); + assertNull(servers); + } + + // --- readServersFromFile --- + + @Test + void readServersFromFile_existingFile_readsCorrectly() throws IOException { + Path mcpJson = tempDir.resolve("mcp.json"); + Files.writeString(mcpJson, """ + { + "servers": { + "test-server": { + "type": "stdio", + "command": "echo" + } + } + } + """, StandardCharsets.UTF_8); + + Map servers = McpFileConfigService.readServersFromFile(mcpJson); + assertNotNull(servers); + assertEquals(1, servers.size()); + assertTrue(servers.containsKey("test-server")); + } + + @Test + void readServersFromFile_nonExistentFile_returnsNull() { + Path nonExistent = tempDir.resolve("does-not-exist.json"); + Map servers = McpFileConfigService.readServersFromFile(nonExistent); + assertNull(servers); + } + + // --- discoverSources --- + + @Test + void discoverSources_findsVscodeConfig() throws IOException { + Path projectRoot = tempDir.resolve("my-project"); + Path vscodeDir = projectRoot.resolve(".vscode"); + Files.createDirectories(vscodeDir); + Files.writeString(vscodeDir.resolve("mcp.json"), """ + { + "servers": { + "vscode-server": {"command": "echo"} + } + } + """, StandardCharsets.UTF_8); + + List sources = McpFileConfigService.discoverMcpJsonFiles(List.of(projectRoot)); + assertEquals(1, sources.size()); + assertEquals("my-project (.vscode)", sources.get(0).getLabel()); + assertEquals(1, sources.get(0).getServerCount()); + } + + @Test + void discoverSources_findsCopilotConfig() throws IOException { + Path projectRoot = tempDir.resolve("my-project"); + Path copilotDir = projectRoot.resolve(".github").resolve("copilot"); + Files.createDirectories(copilotDir); + Files.writeString(copilotDir.resolve("mcp.json"), """ + { + "servers": { + "copilot-server": {"command": "test"} + } + } + """, StandardCharsets.UTF_8); + + List sources = McpFileConfigService.discoverMcpJsonFiles(List.of(projectRoot)); + assertEquals(1, sources.size()); + assertEquals("my-project (.github/copilot)", sources.get(0).getLabel()); + assertEquals(1, sources.get(0).getServerCount()); + } + + @Test + void discoverSources_findsBothProjectConfigs() throws IOException { + Path projectRoot = tempDir.resolve("my-project"); + Path vscodeDir = projectRoot.resolve(".vscode"); + Path copilotDir = projectRoot.resolve(".github").resolve("copilot"); + Files.createDirectories(vscodeDir); + Files.createDirectories(copilotDir); + + Files.writeString(vscodeDir.resolve("mcp.json"), """ + {"servers": {"vs-server": {"command": "a"}}} + """, StandardCharsets.UTF_8); + Files.writeString(copilotDir.resolve("mcp.json"), """ + {"servers": {"cp-server": {"command": "b"}}} + """, StandardCharsets.UTF_8); + + List sources = McpFileConfigService.discoverMcpJsonFiles(List.of(projectRoot)); + assertEquals(2, sources.size()); + // .vscode comes first (lower priority), .github/copilot second (higher priority) + assertTrue(sources.get(0).getLabel().contains(".vscode")); + assertTrue(sources.get(1).getLabel().contains(".github/copilot")); + } + + @Test + void discoverSources_emptyProjectRoots_returnsEmptyOrGlobalOnly() { + List sources = McpFileConfigService.discoverMcpJsonFiles(Collections.emptyList()); + // Only global config could be returned (if it exists on this machine) + // All project-level sources should be absent + for (McpFileSource source : sources) { + assertEquals("Global", source.getLabel()); + } + } + + // --- mergeToJson --- + + @Test + void mergeToJson_mergesMultipleSources() { + Map servers1 = Map.of("server-a", Map.of("command", "a")); + Map servers2 = Map.of("server-b", Map.of("command", "b")); + + McpFileSource source1 = new McpFileSource("Global", tempDir.resolve("global.json"), servers1); + McpFileSource source2 = new McpFileSource("Project", tempDir.resolve("project.json"), servers2); + + String json = McpFileConfigService.mergeMcpFilesToSingleJson(List.of(source1, source2)); + assertNotNull(json); + assertTrue(json.contains("server-a")); + assertTrue(json.contains("server-b")); + assertTrue(json.contains("\"servers\"")); + } + + @Test + void mergeToJson_laterSourceOverridesSameNamedServer() { + Map lowPriority = Map.of("shared-server", Map.of("command", "old-cmd")); + Map highPriority = Map.of("shared-server", Map.of("command", "new-cmd")); + + McpFileSource source1 = new McpFileSource("Global", tempDir.resolve("g.json"), lowPriority); + McpFileSource source2 = new McpFileSource("Project", tempDir.resolve("p.json"), highPriority); + + String json = McpFileConfigService.mergeMcpFilesToSingleJson(List.of(source1, source2)); + assertNotNull(json); + assertTrue(json.contains("new-cmd")); + } + + @Test + void mergeToJson_emptySources_returnsEmptyString() { + String json = McpFileConfigService.mergeMcpFilesToSingleJson(Collections.emptyList()); + assertEquals("", json); + } + + // --- resolveGlobalConfigPath --- + + @Test + void resolveGlobalConfigPath_returnsNonNullPath() { + Path path = McpFileConfigService.resolveGlobalConfigPath(); + // Should return a path as long as user.home is set (always true in tests) + assertNotNull(path); + assertTrue(path.toString().contains("github-copilot")); + assertTrue(path.toString().contains("eclipse")); + assertTrue(path.toString().endsWith("mcp.json")); + } + + // --- McpFileSource --- + + @Test + void mcpFileSource_handlesNullServers() { + McpFileSource source = new McpFileSource("test", tempDir.resolve("t.json"), null); + assertNotNull(source.getServers()); + assertEquals(0, source.getServerCount()); + } + + // --- readServersFromFile path traversal --- + + @Test + void readServersFromFile_symlinkOutsideProject_returnsNull() throws IOException { + // Create a valid mcp.json in an "outside" directory + Path outsideDir = tempDir.resolve("outside"); + Files.createDirectories(outsideDir); + Path realFile = outsideDir.resolve("mcp.json"); + Files.writeString(realFile, """ + {"servers": {"evil-server": {"command": "steal-data"}}} + """, StandardCharsets.UTF_8); + + // Create a symlink inside a "project" that points to the outside file + Path projectDir = tempDir.resolve("project"); + Path vscodeDir = projectDir.resolve(".vscode"); + Files.createDirectories(vscodeDir); + Path symlink = vscodeDir.resolve("mcp.json"); + Files.createSymbolicLink(symlink, realFile); + + // The symlink resolves to a different canonical path, so it should be rejected + Map servers = McpFileConfigService.readServersFromFile(symlink); + assertNull(servers); + } + + // --- loadAndMerge --- + + @Test + void loadAndMerge_withTwoServersFromDifferentFiles_serversLoadedCorrectly() throws IOException { + Path project1 = tempDir.resolve("project1"); + Path project2 = tempDir.resolve("project2"); + Files.createDirectories(project1.resolve(".vscode")); + Files.createDirectories(project2.resolve(".github/copilot")); + + Files.writeString(project1.resolve(".vscode/mcp.json"), """ + {"servers": {"p1-server": {"command": "p1"}}} + """, StandardCharsets.UTF_8); + Files.writeString(project2.resolve(".github/copilot/mcp.json"), """ + {"servers": {"p2-server": {"command": "p2"}}} + """, StandardCharsets.UTF_8); + + String json = McpFileConfigService.loadAndMerge(List.of(project1, project2)); + assertNotNull(json); + assertTrue(json.contains("p1-server")); + assertTrue(json.contains("p2-server")); + } + + @Test + void loadAndMerge_noFiles_returnsEmptyString() { + Path emptyProject = tempDir.resolve("empty-project"); + String json = McpFileConfigService.loadAndMerge(List.of(emptyProject)); + // No files exist, and global config may or may not exist + // Either empty or contains only global servers + assertNotNull(json); + } +} 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 7e2ae6ca..cfa8703c 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 @@ -30,6 +30,10 @@ private Constants() { public static final String AGENT_MAX_REQUESTS = "agentMaxRequests"; public static final String MCP = "mcp"; public static final String MCP_REGISTRY_URL = "mcpRegistryUrl"; + public static final String MCP_FILE_VSCODE = ".vscode/mcp.json"; + public static final String MCP_FILE_COPILOT = ".github/copilot/mcp.json"; + public static final String MCP_FILE_GLOBAL_SUBDIR = "eclipse"; + public static final String MCP_FILE_NAME = "mcp.json"; public static final String MCP_REGISTRY_VERSION = "v0.1"; public static final String MCP_TOOLS_STATUS = "mcpToolsStatus"; public static final String MCP_TOOLS_MODE_STATUS = "mcpToolsModeStatus"; diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotLanguageServerSettings.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotLanguageServerSettings.java index f3a30856..32600734 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotLanguageServerSettings.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotLanguageServerSettings.java @@ -17,6 +17,7 @@ import org.eclipse.jdt.annotation.Nullable; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.utils.McpFileConfigService; /** * Settings for the DidChangeConfigurationParams. @@ -505,21 +506,11 @@ private String parseMcpServers(String mcpServersPreference) { return mcpServersPreference; } - try { - Gson gson = new GsonBuilder().disableHtmlEscaping().create(); - Map jsonMap = gson.fromJson(mcpServersPreference, new TypeToken>() { - }.getType()); - - if (jsonMap != null && jsonMap.containsKey("servers")) { - Object serversObj = jsonMap.get("servers"); - return gson.toJson(serversObj); - } - - return mcpServersPreference; - } catch (JsonParseException e) { - CopilotCore.LOGGER.error("Failed to parse MCP servers JSON", e); + Map servers = McpFileConfigService.parseServers(mcpServersPreference, "preference"); + if (servers == null) { return null; } + return new GsonBuilder().disableHtmlEscaping().create().toJson(servers); } @Override diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/McpFileConfigService.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/McpFileConfigService.java new file mode 100644 index 00000000..f6000798 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/McpFileConfigService.java @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.utils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; + +/** + * Service for discovering and reading MCP server configuration from mcp.json files. + * + *

Supports three levels of configuration (lowest to highest priority): + *

    + *
  1. Global: {@code ~/.config/github-copilot/eclipse/mcp.json} (Linux/macOS) + * or {@code %APPDATA%\github-copilot\eclipse\mcp.json} (Windows)
  2. + *
  3. Project: {@code .vscode/mcp.json} (cross-IDE, per project root)
  4. + *
  5. Project: {@code .github/copilot/mcp.json} (Copilot-specific, per project root)
  6. + *
+ * + *

Servers from all discovered files are merged, with higher-priority sources overriding + * lower-priority ones for servers with the same name. + */ +public final class McpFileConfigService { + + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + private McpFileConfigService() { + // Utility class, not instantiable + } + + /** + * Resolves the global MCP config file path for the current platform. + * + * @return the global mcp.json path, or null if the home directory cannot be determined + */ + public static Path resolveGlobalConfigPath() { + if (PlatformUtils.isWindows()) { + String appData = System.getenv("APPDATA"); + if (StringUtils.isNotBlank(appData)) { + return Paths.get(appData, "github-copilot", Constants.MCP_FILE_GLOBAL_SUBDIR, Constants.MCP_FILE_NAME); + } + } else { + String home = System.getProperty("user.home"); + if (StringUtils.isNotBlank(home)) { + return Paths.get(home, ".config", "github-copilot", Constants.MCP_FILE_GLOBAL_SUBDIR, Constants.MCP_FILE_NAME); + } + } + return null; + } + + /** + * Discovers all mcp.json files and returns them as a list of sources, ordered from lowest to highest priority. + * + * @param projectRoots list of absolute project root paths in the workspace + * @return ordered list of discovered file sources (global first, then projects) + */ + public static List discoverMcpJsonFiles(List projectRoots) { + List sources = new ArrayList<>(); + + // 1. Global config (lowest priority) + Path globalPath = resolveGlobalConfigPath(); + if (globalPath != null) { + Map servers = readServersFromFile(globalPath); + if (servers != null && !servers.isEmpty()) { + sources.add(new McpFileSource("Global", globalPath, servers)); + } + } + + // 2. Project-level configs + if (projectRoots != null) { + for (Path projectRoot : projectRoots) { + if (projectRoot == null || !Files.isDirectory(projectRoot)) { + continue; + } + + // .vscode/mcp.json (cross-IDE, lower project priority) + addMcpFileBasedServers(sources, projectRoot, projectRoot.resolve(Constants.MCP_FILE_VSCODE), ".vscode"); + // .github/copilot/mcp.json (Copilot-specific, higher project priority) + addMcpFileBasedServers(sources, projectRoot, projectRoot.resolve(Constants.MCP_FILE_COPILOT), ".github/copilot"); + } + } + + return sources; + } + + private static void addMcpFileBasedServers(List sources, Path projectRoot, Path mcpFilePath, String labelSuffix) { + Map vscodeServers = readServersFromFile(mcpFilePath); + if (vscodeServers != null && !vscodeServers.isEmpty()) { + String projectName = projectRoot.getFileName().toString(); + sources.add(new McpFileSource(projectName + " ( " + labelSuffix + " )", mcpFilePath, vscodeServers)); + } + } + + /** + * Merges all discovered file-based MCP server configs into a single JSON string. + * + * @param sources ordered list of file sources to merge + * @return merged JSON string, or empty string if no servers found + */ + public static String mergeMcpFilesToSingleJson(List sources) { + if (sources == null || sources.isEmpty()) { + return ""; + } + + Map merged = new LinkedHashMap<>(); + for (McpFileSource source : sources) { + // merged in priority order, so later sources override earlier ones for duplicate server names + merged.putAll(source.getServers()); + } + + if (merged.isEmpty()) { + return ""; + } + + Map wrapper = new LinkedHashMap<>(); + wrapper.put("servers", merged); + return GSON.toJson(wrapper); + } + + /** + * Discovers all file sources from the given project roots, merges them, and returns the JSON string. + * + * @param projectRoots list of absolute project root paths + * @return merged JSON string of all file-based servers, or empty string + */ + public static String loadAndMerge(List projectRoots) { + List sources = discoverMcpJsonFiles(projectRoots); + return mergeMcpFilesToSingleJson(sources); + } + + /** + * Reads and parses the "servers" object from an mcp.json file. + * + *

Accepts two formats: + *

    + *
  • {@code {"servers": {"name": {...}, ...}}} — extracts the "servers" value
  • + *
  • {@code {"name": {...}, ...}} — treats the entire object as servers
  • + *
+ * + * @param filePath path to the mcp.json file + * @return map of server-name → server-config, or null if file doesn't exist or is invalid + */ + @SuppressWarnings("unchecked") + static Map readServersFromFile(Path filePath) { + if (filePath == null || !Files.exists(filePath) || !Files.isRegularFile(filePath)) { + return null; + } + + // Security: verify the file path is canonical (prevent path traversal) + if (!isCanonicalPath(filePath)) { + return null; + } + + String content; + try { + content = Files.readString(filePath, StandardCharsets.UTF_8); + } catch (IOException e) { + CopilotCore.LOGGER.error("Failed to read mcp.json: " + filePath, e); + return null; + } + + if (StringUtils.isBlank(content)) { + return null; + } + + return parseServers(content, filePath.toString()); + } + + private static boolean isCanonicalPath(Path filePath) { + try { + Path canonical = filePath.toRealPath(); + if (!canonical.equals(filePath.toAbsolutePath().normalize())) { + CopilotCore.LOGGER.info("Rejected mcp.json with non-canonical path: " + filePath); + return false; + } + return true; + } catch (IOException e) { + CopilotCore.LOGGER.error("Failed to resolve canonical path for: " + filePath, e); + return false; + } + } + + /** + * Parses the "servers" from an mcp.json content string. + * + * @param mcpJsonContent the JSON content + * @param sourceLabel label for error messages (typically the file path) + * @return map of server-name → server-config, or null on parse failure + */ + @SuppressWarnings("unchecked") + public static Map parseServers(String mcpJsonContent, String sourceLabel) { + try { + Map jsonMap = GSON.fromJson(mcpJsonContent, new TypeToken>() { + }.getType()); + + if (jsonMap == null) { + return null; + } + + // If the top-level has a "servers" key, extract that + if (jsonMap.containsKey("servers")) { + Object serversObj = jsonMap.get("servers"); + if (serversObj instanceof Map) { + return (Map) serversObj; + } + CopilotCore.LOGGER.info("Invalid 'servers' value in " + sourceLabel + ": expected object"); + return null; + } + + // Otherwise treat the entire object as the servers map (VS Code compat) + // But filter out known non-server keys like "inputs" + Map servers = new LinkedHashMap<>(jsonMap); + servers.remove("inputs"); + return servers.isEmpty() ? null : servers; + } catch (JsonParseException e) { + CopilotCore.LOGGER.error("Failed to parse mcp.json: " + sourceLabel, e); + return null; + } + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/McpFileSource.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/McpFileSource.java new file mode 100644 index 00000000..99e741d0 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/McpFileSource.java @@ -0,0 +1,55 @@ +package com.microsoft.copilot.eclipse.core.utils; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; + +/** + * Represents a single discovered mcp.json file and its parsed servers. + * + * @param label human-readable label + * @param filePath absolute path to the mcp.json file + * @param servers parsed server map from the file + */ +public record McpFileSource(String label, Path filePath, Map servers) { + + /** + * Creates a new MCP file source + * + * @param label human-readable label + * @param filePath absolute path to the mcp.json file + * @param servers parsed server map from the file + */ + public McpFileSource { + // normalize null servers to an empty unmodifiable map + servers = servers != null ? Collections.unmodifiableMap(servers) : Collections.emptyMap(); + } + + /** + * @return human-readable label + */ + public String getLabel() { + return label; + } + + /** + * @return absolute path to the mcp.json file + */ + public Path getFilePath() { + return filePath; + } + + /** + * @return unmodifiable map of parsed servers + */ + public Map getServers() { + return servers; + } + + /** + * @return number of servers in this source + */ + public int getServerCount() { + return servers.size(); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/plugin.properties b/com.microsoft.copilot.eclipse.ui/plugin.properties index 38fd45a7..9f650c25 100644 --- a/com.microsoft.copilot.eclipse.ui/plugin.properties +++ b/com.microsoft.copilot.eclipse.ui/plugin.properties @@ -35,6 +35,7 @@ page.preferencesPage.general.name=General page.preferencesPage.chat.name=Chat page.preferencesPage.completions.name=Completions page.preferencesPage.mcp.name=Model Context Protocol (MCP) +page.preferencesPage.mcp.fileConfigs.name=File-based Servers page.preferencesPage.customInstructions.name=Custom Instructions page.preferencesPage.byok.name=Model Management page.preferencesPage.customAgents.name=Custom Agents diff --git a/com.microsoft.copilot.eclipse.ui/plugin.xml b/com.microsoft.copilot.eclipse.ui/plugin.xml index bcd4c053..20aa5c65 100644 --- a/com.microsoft.copilot.eclipse.ui/plugin.xml +++ b/com.microsoft.copilot.eclipse.ui/plugin.xml @@ -157,6 +157,12 @@ category="com.microsoft.copilot.eclipse.ui.preferences.CopilotPreferencesPage" class="com.microsoft.copilot.eclipse.ui.preferences.McpPreferencePage"> + + getProjectRootPaths() { + return WorkspaceUtils.listTopLevelProjects().stream() + .map(IProject::getLocation) + .map(loc -> loc.toFile().toPath()) + .toList(); + } /** * Initializes the MCP tools status from the preference store for built-in agent mode only. @@ -521,5 +543,39 @@ public void setAutoShowCompletion(boolean autoShowCompletion) { */ public void dispose() { proxyService.removeProxyChangeListener(this); + if (mcpFileChangeListener != null) { + ResourcesPlugin.getWorkspace().removeResourceChangeListener(mcpFileChangeListener); + } + } + + private void registerMcpFileChangeListener() { + mcpFileChangeListener = event -> { + if (event.getDelta() != null && containsMcpJsonChange(event.getDelta())) { + syncMcpRegistrationConfiguration(); + } + }; + ResourcesPlugin.getWorkspace().addResourceChangeListener(mcpFileChangeListener, + IResourceChangeEvent.POST_CHANGE); + } + + /** + * Checks whether a resource delta contains a change to an mcp.json file + * at one of the supported project-level locations. + */ + private boolean containsMcpJsonChange(IResourceDelta delta) { + boolean[] found = { false }; + try { + delta.accept(d -> { + String path = d.getResource().getProjectRelativePath().toString(); + if (Constants.MCP_FILE_VSCODE.equals(path) || Constants.MCP_FILE_COPILOT.equals(path)) { + found[0] = true; + return false; + } + return true; + }); + } catch (CoreException e) { + CopilotCore.LOGGER.error("Error checking for MCP file changes", e); + } + return found[0]; } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpFileConfigPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpFileConfigPreferencePage.java new file mode 100644 index 00000000..403d8b68 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpFileConfigPreferencePage.java @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.core.resources.IProject; +import org.eclipse.jface.layout.GridDataFactory; +import org.eclipse.jface.preference.PreferencePage; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; +import org.eclipse.swt.widgets.TableItem; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPreferencePage; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.utils.McpFileConfigService; +import com.microsoft.copilot.eclipse.core.utils.McpFileSource; +import com.microsoft.copilot.eclipse.core.utils.WorkspaceUtils; + +/** + * Preference sub-page showing discovered file-based MCP server configurations. + */ +public class McpFileConfigPreferencePage extends PreferencePage implements IWorkbenchPreferencePage { + + public static final String ID = "com.microsoft.copilot.eclipse.ui.preferences.McpFileConfigPreferencePage"; + + @Override + public void init(IWorkbench workbench) { + noDefaultAndApplyButton(); + } + + @Override + protected Control createContents(Composite parent) { + Composite container = new Composite(parent, SWT.NONE); + container.setLayout(new GridLayout(1, true)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + createDescriptionArea(container); + createDiscoveredFilesArea(container); + createSupportedLocationsArea(container); + + return container; + } + + private void createDescriptionArea(Composite parent) { + PreferencePageUtils.createExternalLink(parent, + Messages.preferences_page_mcp_file_configs_description, null); + } + + private void createDiscoveredFilesArea(Composite parent) { + GridLayout gl = new GridLayout(1, true); + gl.marginTop = 2; + gl.marginLeft = 2; + + Group group = new Group(parent, SWT.NONE); + group.setLayout(gl); + GridDataFactory.fillDefaults().span(2, 1).align(SWT.FILL, SWT.FILL).grab(true, true).applyTo(group); + group.setText(Messages.preferences_page_mcp_file_configs_discovered); + + List sources = McpFileConfigService.discoverMcpJsonFiles(getProjectRootPaths()); + + if (sources.isEmpty()) { + Label noneLabel = new Label(group, SWT.WRAP); + noneLabel.setText(Messages.preferences_page_mcp_file_configs_none); + noneLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + } else { + Table table = new Table(group, SWT.BORDER | SWT.FULL_SELECTION); + table.setHeaderVisible(true); + table.setLinesVisible(true); + GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, true); + tableData.heightHint = 150; + table.setLayoutData(tableData); + + TableColumn sourceCol = new TableColumn(table, SWT.LEFT); + sourceCol.setText(Messages.preferences_page_mcp_file_configs_col_source); + sourceCol.setWidth(150); + + TableColumn pathCol = new TableColumn(table, SWT.LEFT); + pathCol.setText(Messages.preferences_page_mcp_file_configs_col_path); + pathCol.setWidth(350); + + TableColumn countCol = new TableColumn(table, SWT.LEFT); + countCol.setText(Messages.preferences_page_mcp_file_configs_col_servers); + countCol.setWidth(70); + + for (McpFileSource source : sources) { + TableItem item = new TableItem(table, SWT.NONE); + item.setText(0, source.getLabel()); + item.setText(1, source.getFilePath().toString()); + item.setText(2, String.valueOf(source.getServerCount())); + } + } + + // Merge-order note + new WrappableNoteLabel(group, Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_mcp_file_configs_merge_note); + } + + private void createSupportedLocationsArea(Composite parent) { + GridLayout gl = new GridLayout(1, true); + gl.marginTop = 2; + gl.marginLeft = 2; + + Group group = new Group(parent, SWT.NONE); + group.setLayout(gl); + GridDataFactory.fillDefaults().span(2, 1).align(SWT.FILL, SWT.FILL).grab(true, false).applyTo(group); + group.setText(Messages.preferences_page_mcp_file_configs_locations); + + // Global path + Path globalPath = McpFileConfigService.resolveGlobalConfigPath(); + String globalPathStr = globalPath != null ? globalPath.toString() + : Messages.preferences_page_mcp_file_configs_unknown; + addLocationRow(group, Messages.preferences_page_mcp_file_configs_loc_global, globalPathStr); + + // Project paths + addLocationRow(group, Messages.preferences_page_mcp_file_configs_loc_project_vscode, + "/.vscode/mcp.json"); + addLocationRow(group, Messages.preferences_page_mcp_file_configs_loc_project_copilot, + "/.github/copilot/mcp.json"); + } + + private void addLocationRow(Composite parent, String label, String path) { + Composite row = new Composite(parent, SWT.NONE); + GridLayout rowLayout = new GridLayout(2, false); + rowLayout.marginWidth = 0; + rowLayout.marginHeight = 2; + row.setLayout(rowLayout); + row.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + + Label labelWidget = new Label(row, SWT.NONE); + labelWidget.setText(label); + GridData labelData = new GridData(SWT.LEFT, SWT.CENTER, false, false); + labelData.widthHint = 160; + labelWidget.setLayoutData(labelData); + + Label pathWidget = new Label(row, SWT.WRAP); + pathWidget.setText(path); + pathWidget.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + } + + /** + * Returns the filesystem paths of all top-level workspace projects. + * + * @return list of project root paths + */ + private List getProjectRootPaths() { + List roots = new ArrayList<>(); + for (IProject project : WorkspaceUtils.listTopLevelProjects()) { + if (project.getLocationURI() != null) { + try { + roots.add(Paths.get(project.getLocationURI())); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to resolve project path: " + project.getName(), e); + } + } + } + return roots; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java index bce65aed..29761041 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java @@ -73,6 +73,18 @@ public class Messages extends NLS { public static String preferences_page_github_enterprise_tooltip; public static String preferences_page_mcp_tooltip; public static String preferences_page_mcp_note_content; + public static String preferences_page_mcp_file_configs_none; + public static String preferences_page_mcp_file_configs_description; + public static String preferences_page_mcp_file_configs_discovered; + public static String preferences_page_mcp_file_configs_col_source; + public static String preferences_page_mcp_file_configs_col_path; + public static String preferences_page_mcp_file_configs_col_servers; + public static String preferences_page_mcp_file_configs_merge_note; + public static String preferences_page_mcp_file_configs_locations; + public static String preferences_page_mcp_file_configs_loc_global; + public static String preferences_page_mcp_file_configs_loc_project_vscode; + public static String preferences_page_mcp_file_configs_loc_project_copilot; + public static String preferences_page_mcp_file_configs_unknown; public static String preferences_page_mcp_disabled_tip; public static String preferences_page_mcp_tools_settings; public static String preferences_page_mcp_tools_mode_selector; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties index 2b06110a..6f9ebfd0 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties @@ -59,6 +59,18 @@ preferences_page_mcp_tooltip= Model Context Protocol server configurations. preferences_page_note_prefix= Note: preferences_page_note_content= You can configure proxy settings in Eclipse by clicking on the Configure Proxy link. The Strict SSL option takes effect only when you are accessing GitHub Copilot from a network protected by a proxy server. preferences_page_mcp_note_content= Click 'Restore Defaults' to see configuration examples. +preferences_page_mcp_file_configs_none= No mcp.json files found. See the Supported Locations section below for expected file paths. +preferences_page_mcp_file_configs_description= MCP servers can be configured via mcp.json files at global and project levels. Servers from all sources are merged, with higher-priority sources overriding lower ones for same-named servers. Learn more about mcp.json format +preferences_page_mcp_file_configs_discovered= Discovered Configurations +preferences_page_mcp_file_configs_col_source= Source +preferences_page_mcp_file_configs_col_path= File Path +preferences_page_mcp_file_configs_col_servers= Servers +preferences_page_mcp_file_configs_merge_note= Files are merged in order: global, then project .vscode/mcp.json, then project .github/copilot/mcp.json. The manual configuration and plug-in contributions on the parent page take highest priority. +preferences_page_mcp_file_configs_locations= Supported Locations +preferences_page_mcp_file_configs_loc_global= Global (user): +preferences_page_mcp_file_configs_loc_project_vscode= Project (cross-IDE): +preferences_page_mcp_file_configs_loc_project_copilot= Project (Copilot): +preferences_page_mcp_file_configs_unknown= (unable to determine path) preferences_page_mcp_disabled_tip=MCP servers are disabled by your organization's policy. To enable them, please contact your administrator. Get More Info about Copilot policies preferences_page_mcp_tools_settings= Tools (Select tools that are available to agent) preferences_page_mcp_tools_mode_selector=Configure tools for mode: diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java index efd3a7c8..1f251fe6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java @@ -10,6 +10,7 @@ import com.microsoft.copilot.eclipse.ui.preferences.CustomInstructionPreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.CustomModesPreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.GeneralPreferencesPage; +import com.microsoft.copilot.eclipse.ui.preferences.McpFileConfigPreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.McpPreferencePage; /** @@ -24,7 +25,7 @@ private PreferencesUtils() { public static String[] getAllPreferenceIds() { return new String[] { CopilotPreferencesPage.ID, GeneralPreferencesPage.ID, ChatPreferencesPage.ID, CompletionsPreferencesPage.ID, CustomInstructionPreferencePage.ID, CustomModesPreferencePage.ID, - McpPreferencePage.ID, ByokPreferencePage.ID }; + McpPreferencePage.ID, McpFileConfigPreferencePage.ID, ByokPreferencePage.ID }; } }