diff --git a/Partner-Main/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java b/Partner-Main/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java index 46396555..8a26f163 100644 --- a/Partner-Main/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java +++ b/Partner-Main/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java @@ -1,7 +1,6 @@ package work.slhaf.partner.core.action.runner; import cn.hutool.core.io.FileUtil; -import cn.hutool.core.io.IORuntimeException; import cn.hutool.json.JSONUtil; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; @@ -1079,22 +1078,28 @@ public class LocalRunnerClient extends RunnerClient { private void registerMcpClient(String id, McpClientTransportParams mcpClientTransportParams) { // 如果已存在同名 client,则需要先获取并关闭 val old = mcpClients.get(id); - if (old != null) { - old.close(); - } - val clientTransport = createTransport(mcpClientTransportParams); val timeout = mcpClientTransportParams.timeout; val client = McpClient.sync(clientTransport) .requestTimeout(Duration.ofSeconds(timeout)) .clientInfo(new McpSchema.Implementation(id, "PARTNER")) .build(); - mcpClients.put(id, client); - for (McpSchema.Tool tool : client.listTools().tools()) { - val metaActionInfo = buildMetaActionInfo(tool); - existedMetaActions.put(id + "::" + tool.name(), metaActionInfo); + try { + for (McpSchema.Tool tool : client.listTools().tools()) { + val metaActionInfo = buildMetaActionInfo(tool); + existedMetaActions.put(id + "::" + tool.name(), metaActionInfo); + } + mcpClients.put(id, client); + + if (old != null) { + old.close(); + } + } catch (Exception e) { + log.warn("[{}] MCP client init failed, skipped (probably non-stdio-safe)", id, e); + client.close(); } + } @@ -1105,12 +1110,15 @@ public class LocalRunnerClient extends RunnerClient { info.setResponseSchema(outputSchema == null ? JSONObject.of() : JSONObject.from(outputSchema)); info.setParams(tool.inputSchema().properties()); - JSONObject meta = JSONObject.from(tool.meta()); - info.setIo(meta.getBoolean("io")); - info.setPreActions(meta.getList("pre", String.class)); - info.setPostActions(meta.getList("post", String.class)); - info.setStrictDependencies(meta.getBoolean("strict")); - info.setTags(meta.getList("tag", String.class)); + val meta = tool.meta(); + if (meta != null) { + JSONObject metaJson = JSONObject.from(meta); + info.setIo(metaJson.getBoolean("io")); + info.setPreActions(metaJson.getList("pre", String.class)); + info.setPostActions(metaJson.getList("post", String.class)); + info.setStrictDependencies(metaJson.getBoolean("strict")); + info.setTags(metaJson.getList("tag", String.class)); + } return info; } @@ -1167,7 +1175,7 @@ public class LocalRunnerClient extends RunnerClient { private cn.hutool.json.JSONObject readJson(File file) { try { return JSONUtil.readJSONObject(file, StandardCharsets.UTF_8); - } catch (IORuntimeException ignored) { + } catch (Exception ignored) { return null; } } @@ -1358,7 +1366,7 @@ public class LocalRunnerClient extends RunnerClient { protected LocalWatchServiceBuild.EventHandler buildDelete() { return (thisDir, context) -> { val file = context.toFile(); - if (!file.isFile() || !file.getName().endsWith(".json")) { + if (!file.getName().endsWith(".json")) { return; } diff --git a/Partner-Main/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java b/Partner-Main/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java index 4a77ff09..0353f375 100644 --- a/Partner-Main/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java +++ b/Partner-Main/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java @@ -16,9 +16,10 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.BooleanSupplier; +import java.util.function.Predicate; import static work.slhaf.partner.core.action.runner.LocalRunnerClientTest.Await.waitForCondition; -import static work.slhaf.partner.core.action.runner.LocalRunnerClientTest.Common.getMetaActionInfo; +import static work.slhaf.partner.core.action.runner.LocalRunnerClientTest.Common.*; import static work.slhaf.partner.core.action.runner.LocalRunnerClientTest.Fs.*; @Slf4j @@ -86,6 +87,10 @@ public class LocalRunnerClientTest { } } + static void writeCommonMcpConfig(Path filePath, String content) throws IOException { + Files.writeString(filePath, content); + } + } @SuppressWarnings("BusyWait") @@ -106,6 +111,35 @@ public class LocalRunnerClientTest { String actionKey) { return existedMetaActions.get(actionKey); } + + static boolean hasActionKey(ConcurrentHashMap existedMetaActions, + Predicate predicate) { + return existedMetaActions.keySet().stream().anyMatch(predicate); + } + + static String buildCommonMcpConfig(String... serverEntries) { + StringBuilder builder = new StringBuilder(); + builder.append("{\n"); + for (int i = 0; i < serverEntries.length; i++) { + builder.append(serverEntries[i]); + if (i < serverEntries.length - 1) { + builder.append(",\n"); + } + } + builder.append("\n}\n"); + return builder.toString(); + } + + static String buildStdioServerEntry(String id, String packageName) { + return " \"" + id + "\": {\n" + + " \"command\": \"npx\",\n" + + " \"args\": [\n" + + " \"-y\",\n" + + " \"" + packageName + "\"\n" + + " ],\n" + + " \"env\": {}\n" + + " }"; + } } @Nested @@ -298,4 +332,92 @@ public class LocalRunnerClientTest { } } + @Nested + class CommonMcpTest { + + @Test + void testCommonMcpInitialLoad(@TempDir Path tempDir) throws IOException, InterruptedException { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + + Path mcpDir = tempDir.resolve("action").resolve("mcp"); + Files.createDirectories(mcpDir); + Path configFile = mcpDir.resolve("servers.json"); + String config = buildCommonMcpConfig( + buildStdioServerEntry("mcp-deepwiki", "mcp-deepwiki@latest") + ); + writeCommonMcpConfig(configFile, config); + + new LocalRunnerClient(existedMetaActions, executor, tempDir.toString()); + + try { + waitForCondition(() -> hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::")), 20000); + Assertions.assertTrue(hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::"))); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testCommonMcpCreateModifyDelete(@TempDir Path tempDir) throws IOException, InterruptedException { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + new LocalRunnerClient(existedMetaActions, executor, tempDir.toString()); + + try { + Path mcpDir = tempDir.resolve("action").resolve("mcp"); + Files.createDirectories(mcpDir); + Path configFile = mcpDir.resolve("servers.json"); + + String config = buildCommonMcpConfig( + buildStdioServerEntry("mcp-deepwiki", "mcp-deepwiki@latest") + ); + writeCommonMcpConfig(configFile, config); + waitForCondition(() -> hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::")), 20000); + Assertions.assertTrue(hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::"))); + + String updatedConfig = buildCommonMcpConfig( + buildStdioServerEntry("mcp-deepwiki", "mcp-deepwiki@latest"), + buildStdioServerEntry("playwright", "@playwright/mcp@latest") + ); + writeCommonMcpConfig(configFile, updatedConfig); + waitForCondition(() -> hasActionKey(existedMetaActions, key -> key.startsWith("playwright::")), 20000); + Assertions.assertTrue(hasActionKey(existedMetaActions, key -> key.startsWith("playwright::"))); + + Files.deleteIfExists(configFile); + waitForCondition(() -> !hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::")), 20000); + Assertions.assertFalse(hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::"))); + Assertions.assertFalse(hasActionKey(existedMetaActions, key -> key.startsWith("playwright::"))); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testCommonMcpInvalidJsonRecovery(@TempDir Path tempDir) throws IOException, InterruptedException { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + new LocalRunnerClient(existedMetaActions, executor, tempDir.toString()); + + try { + Path mcpDir = tempDir.resolve("action").resolve("mcp"); + Files.createDirectories(mcpDir); + Path configFile = mcpDir.resolve("servers.json"); + + writeCommonMcpConfig(configFile, "{ invalid json"); + waitForCondition(() -> hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::")), 2000); + Assertions.assertFalse(hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::"))); + + String config = buildCommonMcpConfig( + buildStdioServerEntry("mcp-deepwiki", "mcp-deepwiki@latest") + ); + writeCommonMcpConfig(configFile, config); + waitForCondition(() -> hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::")), 20000); + Assertions.assertTrue(hasActionKey(existedMetaActions, key -> key.startsWith("mcp-deepwiki::"))); + } finally { + executor.shutdownNow(); + } + } + } + }