fix(LocalRunnerClient): guard against null tool meta and ignore non-protocol MCP

This commit is contained in:
2026-01-14 16:10:33 +08:00
parent fdf398b86e
commit 200c0f3f13
2 changed files with 148 additions and 18 deletions

View File

@@ -1,7 +1,6 @@
package work.slhaf.partner.core.action.runner; package work.slhaf.partner.core.action.runner;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IORuntimeException;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
@@ -1079,22 +1078,28 @@ public class LocalRunnerClient extends RunnerClient {
private void registerMcpClient(String id, McpClientTransportParams mcpClientTransportParams) { private void registerMcpClient(String id, McpClientTransportParams mcpClientTransportParams) {
// 如果已存在同名 client则需要先获取并关闭 // 如果已存在同名 client则需要先获取并关闭
val old = mcpClients.get(id); val old = mcpClients.get(id);
if (old != null) {
old.close();
}
val clientTransport = createTransport(mcpClientTransportParams); val clientTransport = createTransport(mcpClientTransportParams);
val timeout = mcpClientTransportParams.timeout; val timeout = mcpClientTransportParams.timeout;
val client = McpClient.sync(clientTransport) val client = McpClient.sync(clientTransport)
.requestTimeout(Duration.ofSeconds(timeout)) .requestTimeout(Duration.ofSeconds(timeout))
.clientInfo(new McpSchema.Implementation(id, "PARTNER")) .clientInfo(new McpSchema.Implementation(id, "PARTNER"))
.build(); .build();
mcpClients.put(id, client);
for (McpSchema.Tool tool : client.listTools().tools()) { try {
val metaActionInfo = buildMetaActionInfo(tool); for (McpSchema.Tool tool : client.listTools().tools()) {
existedMetaActions.put(id + "::" + tool.name(), metaActionInfo); 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.setResponseSchema(outputSchema == null ? JSONObject.of() : JSONObject.from(outputSchema));
info.setParams(tool.inputSchema().properties()); info.setParams(tool.inputSchema().properties());
JSONObject meta = JSONObject.from(tool.meta()); val meta = tool.meta();
info.setIo(meta.getBoolean("io")); if (meta != null) {
info.setPreActions(meta.getList("pre", String.class)); JSONObject metaJson = JSONObject.from(meta);
info.setPostActions(meta.getList("post", String.class)); info.setIo(metaJson.getBoolean("io"));
info.setStrictDependencies(meta.getBoolean("strict")); info.setPreActions(metaJson.getList("pre", String.class));
info.setTags(meta.getList("tag", String.class)); info.setPostActions(metaJson.getList("post", String.class));
info.setStrictDependencies(metaJson.getBoolean("strict"));
info.setTags(metaJson.getList("tag", String.class));
}
return info; return info;
} }
@@ -1167,7 +1175,7 @@ public class LocalRunnerClient extends RunnerClient {
private cn.hutool.json.JSONObject readJson(File file) { private cn.hutool.json.JSONObject readJson(File file) {
try { try {
return JSONUtil.readJSONObject(file, StandardCharsets.UTF_8); return JSONUtil.readJSONObject(file, StandardCharsets.UTF_8);
} catch (IORuntimeException ignored) { } catch (Exception ignored) {
return null; return null;
} }
} }
@@ -1358,7 +1366,7 @@ public class LocalRunnerClient extends RunnerClient {
protected LocalWatchServiceBuild.EventHandler buildDelete() { protected LocalWatchServiceBuild.EventHandler buildDelete() {
return (thisDir, context) -> { return (thisDir, context) -> {
val file = context.toFile(); val file = context.toFile();
if (!file.isFile() || !file.getName().endsWith(".json")) { if (!file.getName().endsWith(".json")) {
return; return;
} }

View File

@@ -16,9 +16,10 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.function.BooleanSupplier; 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.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.*; import static work.slhaf.partner.core.action.runner.LocalRunnerClientTest.Fs.*;
@Slf4j @Slf4j
@@ -86,6 +87,10 @@ public class LocalRunnerClientTest {
} }
} }
static void writeCommonMcpConfig(Path filePath, String content) throws IOException {
Files.writeString(filePath, content);
}
} }
@SuppressWarnings("BusyWait") @SuppressWarnings("BusyWait")
@@ -106,6 +111,35 @@ public class LocalRunnerClientTest {
String actionKey) { String actionKey) {
return existedMetaActions.get(actionKey); return existedMetaActions.get(actionKey);
} }
static boolean hasActionKey(ConcurrentHashMap<String, MetaActionInfo> existedMetaActions,
Predicate<String> 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 @Nested
@@ -298,4 +332,92 @@ public class LocalRunnerClientTest {
} }
} }
@Nested
class CommonMcpTest {
@Test
void testCommonMcpInitialLoad(@TempDir Path tempDir) throws IOException, InterruptedException {
ConcurrentHashMap<String, MetaActionInfo> 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<String, MetaActionInfo> 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<String, MetaActionInfo> 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();
}
}
}
} }