初始化
This commit is contained in:
66
easy-agents-mcp/pom.xml
Normal file
66
easy-agents-mcp/pom.xml
Normal file
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-parent</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<name>easy-agents-mcp</name>
|
||||
<artifactId>easy-agents-mcp</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<mcp.version>0.17.0</mcp.version>
|
||||
</properties>
|
||||
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-core</artifactId>
|
||||
<version>${mcp.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp</artifactId>
|
||||
<version>${mcp.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-json</artifactId>
|
||||
<version>${mcp.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-json-jackson2</artifactId>
|
||||
<version>${mcp.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-test</artifactId>
|
||||
<version>${mcp.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>io.modelcontextprotocol.sdk</groupId>-->
|
||||
<!-- <artifactId>mcp-spring-webflux</artifactId>-->
|
||||
<!-- <version>${mcp.version}</version>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>io.modelcontextprotocol.sdk</groupId>-->
|
||||
<!-- <artifactId>mcp-spring-webmvc</artifactId>-->
|
||||
<!-- <version>${mcp.version}</version>-->
|
||||
<!-- </dependency>-->
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* <p>
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* <p>
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
|
||||
import java.io.Closeable;
|
||||
|
||||
public interface CloseableTransport extends Closeable {
|
||||
McpClientTransport getTransport();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* <p>
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* <p>
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class HttpSseTransportFactory implements McpTransportFactory {
|
||||
|
||||
@Override
|
||||
public CloseableTransport create(McpConfig.ServerSpec spec, Map<String, String> resolvedEnv) {
|
||||
String url = spec.getUrl();
|
||||
if (url == null || url.isEmpty()) {
|
||||
throw new IllegalArgumentException("URL is required for HTTP SSE transport");
|
||||
}
|
||||
|
||||
|
||||
HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder(url).jsonMapper(McpJsonMapper.getDefault())
|
||||
.build();
|
||||
|
||||
return new CloseableTransport() {
|
||||
@Override
|
||||
public McpClientTransport getTransport() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
transport.close();
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* <p>
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* <p>
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class HttpStreamTransportFactory implements McpTransportFactory {
|
||||
|
||||
@Override
|
||||
public CloseableTransport create(McpConfig.ServerSpec spec, Map<String, String> resolvedEnv) {
|
||||
String url = spec.getUrl();
|
||||
if (url == null || url.isEmpty()) {
|
||||
throw new IllegalArgumentException("URL is required for HTTP Stream transport");
|
||||
}
|
||||
|
||||
HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(url)
|
||||
.build();
|
||||
|
||||
return new CloseableTransport() {
|
||||
@Override
|
||||
public McpClientTransport getTransport() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
transport.close();
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
public class McpCallException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* Constructs a new runtime exception with the specified detail
|
||||
* message, cause, suppression enabled or disabled, and writable
|
||||
* stack trace enabled or disabled.
|
||||
*
|
||||
* @param message the detail message.
|
||||
* @param cause the cause. (A {@code null} value is permitted,
|
||||
* and indicates that the cause is nonexistent or unknown.)
|
||||
* @param enableSuppression whether or not suppression is enabled
|
||||
* or disabled
|
||||
* @param writableStackTrace whether or not the stack trace should
|
||||
* be writable
|
||||
* @since 1.7
|
||||
*/
|
||||
protected McpCallException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new runtime exception with the specified cause and a
|
||||
* detail message of {@code (cause==null ? null : cause.toString())}
|
||||
* (which typically contains the class and detail message of
|
||||
* {@code cause}). This constructor is useful for runtime exceptions
|
||||
* that are little more than wrappers for other throwables.
|
||||
*
|
||||
* @param cause the cause (which is saved for later retrieval by the
|
||||
* {@link #getCause()} method). (A {@code null} value is
|
||||
* permitted, and indicates that the cause is nonexistent or
|
||||
* unknown.)
|
||||
* @since 1.4
|
||||
*/
|
||||
public McpCallException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new runtime exception with the specified detail message and
|
||||
* cause. <p>Note that the detail message associated with
|
||||
* {@code cause} is <i>not</i> automatically incorporated in
|
||||
* this runtime exception's detail message.
|
||||
*
|
||||
* @param message the detail message (which is saved for later retrieval
|
||||
* by the {@link #getMessage()} method).
|
||||
* @param cause the cause (which is saved for later retrieval by the
|
||||
* {@link #getCause()} method). (A {@code null} value is
|
||||
* permitted, and indicates that the cause is nonexistent or
|
||||
* unknown.)
|
||||
* @since 1.4
|
||||
*/
|
||||
public McpCallException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new runtime exception with the specified detail message.
|
||||
* The cause is not initialized, and may subsequently be initialized by a
|
||||
* call to {@link #initCause}.
|
||||
*
|
||||
* @param message the detail message. The detail message is saved for
|
||||
* later retrieval by the {@link #getMessage()} method.
|
||||
*/
|
||||
public McpCallException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new runtime exception with {@code null} as its
|
||||
* detail message. The cause is not initialized, and may subsequently be
|
||||
* initialized by a call to {@link #initCause}.
|
||||
*/
|
||||
public McpCallException() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* <p>
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* <p>
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import com.easyagents.core.model.chat.tool.Tool;
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class McpClientDescriptor {
|
||||
private static final Logger log = LoggerFactory.getLogger(McpClientDescriptor.class);
|
||||
|
||||
private final String name;
|
||||
private final McpConfig.ServerSpec spec;
|
||||
private final Map<String, String> resolvedEnv;
|
||||
|
||||
private volatile McpSyncClient client;
|
||||
private volatile CloseableTransport managedTransport;
|
||||
private volatile boolean closed = false;
|
||||
private final AtomicBoolean initializing = new AtomicBoolean(false);
|
||||
|
||||
private volatile boolean alive = false;
|
||||
private volatile Instant lastPingTime = Instant.EPOCH;
|
||||
private static final long MIN_PING_INTERVAL_MS = 5_000;
|
||||
|
||||
public McpClientDescriptor(String name, McpConfig.ServerSpec spec, Map<String, String> resolvedEnv) {
|
||||
this.name = name;
|
||||
this.spec = spec;
|
||||
this.resolvedEnv = new HashMap<>(resolvedEnv);
|
||||
}
|
||||
|
||||
synchronized McpSyncClient getClient() {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("MCP client closed: " + name);
|
||||
}
|
||||
if (client == null) {
|
||||
initialize();
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
public Tool getMcpTool(String toolName) {
|
||||
McpSyncClient client = getClient();
|
||||
McpSchema.ListToolsResult listToolsResult = client.listTools();
|
||||
for (McpSchema.Tool tool : listToolsResult.tools()) {
|
||||
if (tool.name().equals(toolName)) {
|
||||
return new McpTool(getClient(), tool);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private synchronized void initialize() {
|
||||
if (client != null || closed) return;
|
||||
if (!initializing.compareAndSet(false, true)) {
|
||||
while (client == null && !closed) {
|
||||
try {
|
||||
wait(100);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("Initialization interrupted", e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
McpTransportFactory factory = getTransportFactory(spec.getTransport());
|
||||
CloseableTransport transport = factory.create(spec, resolvedEnv);
|
||||
this.managedTransport = transport;
|
||||
|
||||
McpSyncClient c = McpClient.sync(transport.getTransport())
|
||||
.requestTimeout(java.time.Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
c.initialize();
|
||||
this.client = c;
|
||||
this.alive = true;
|
||||
log.info("MCP client initialized: {}", name);
|
||||
|
||||
} catch (Exception e) {
|
||||
String errorMsg = "Failed to initialize MCP client: " + name + ", error: " + e.getMessage();
|
||||
log.error(errorMsg, e);
|
||||
if (managedTransport != null) {
|
||||
try {
|
||||
managedTransport.close();
|
||||
} catch (Exception closeEx) {
|
||||
log.warn("Error closing transport during init failure", closeEx);
|
||||
}
|
||||
}
|
||||
throw new RuntimeException(errorMsg, e);
|
||||
} finally {
|
||||
initializing.set(false);
|
||||
notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
boolean pingIfNeeded() {
|
||||
if (closed || client == null) {
|
||||
alive = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if ((now - lastPingTime.toEpochMilli()) < MIN_PING_INTERVAL_MS) {
|
||||
return alive;
|
||||
}
|
||||
|
||||
try {
|
||||
client.ping();
|
||||
alive = true;
|
||||
} catch (Exception e) {
|
||||
alive = false;
|
||||
String msg = String.format("Ping failed for MCP client '%s': %s", name, e.getMessage());
|
||||
log.debug(msg);
|
||||
} finally {
|
||||
lastPingTime = Instant.now();
|
||||
}
|
||||
return alive;
|
||||
}
|
||||
|
||||
boolean isAlive() {
|
||||
return alive && !closed && client != null;
|
||||
}
|
||||
|
||||
boolean isClosed() {
|
||||
return closed;
|
||||
}
|
||||
|
||||
synchronized void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
|
||||
if (client != null) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
|
||||
if (managedTransport != null) {
|
||||
try {
|
||||
managedTransport.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error closing transport for '{}'", name, e);
|
||||
}
|
||||
managedTransport = null;
|
||||
}
|
||||
|
||||
alive = false;
|
||||
log.info("MCP client closed: {}", name);
|
||||
}
|
||||
|
||||
private McpTransportFactory getTransportFactory(String transportType) {
|
||||
switch (transportType.toLowerCase()) {
|
||||
case "stdio":
|
||||
return new StdioTransportFactory();
|
||||
case "http-sse":
|
||||
return new HttpSseTransportFactory();
|
||||
case "http-stream":
|
||||
return new HttpStreamTransportFactory();
|
||||
default:
|
||||
throw new IllegalArgumentException("Unsupported transport: " + transportType);
|
||||
}
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public McpConfig.ServerSpec getSpec() {
|
||||
return spec;
|
||||
}
|
||||
|
||||
public Map<String, String> getResolvedEnv() {
|
||||
return resolvedEnv;
|
||||
}
|
||||
|
||||
public void setClient(McpSyncClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public CloseableTransport getManagedTransport() {
|
||||
return managedTransport;
|
||||
}
|
||||
|
||||
public void setManagedTransport(CloseableTransport managedTransport) {
|
||||
this.managedTransport = managedTransport;
|
||||
}
|
||||
|
||||
public void setClosed(boolean closed) {
|
||||
this.closed = closed;
|
||||
}
|
||||
|
||||
public AtomicBoolean getInitializing() {
|
||||
return initializing;
|
||||
}
|
||||
|
||||
public void setAlive(boolean alive) {
|
||||
this.alive = alive;
|
||||
}
|
||||
|
||||
public Instant getLastPingTime() {
|
||||
return lastPingTime;
|
||||
}
|
||||
|
||||
public void setLastPingTime(Instant lastPingTime) {
|
||||
this.lastPingTime = lastPingTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* <p>
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* <p>
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import com.easyagents.core.model.chat.tool.Tool;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class McpClientManager implements AutoCloseable {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(McpClientManager.class);
|
||||
private static volatile McpClientManager INSTANCE;
|
||||
|
||||
private final Map<String, McpClientDescriptor> descriptorRegistry = new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService healthChecker;
|
||||
|
||||
|
||||
private static final String CONFIG_RESOURCE_PROPERTY = "mcp.config.servers-resource";
|
||||
private static final String DEFAULT_CONFIG_RESOURCE = "mcp-servers.json";
|
||||
|
||||
private McpClientManager() {
|
||||
this.healthChecker = Executors.newSingleThreadScheduledExecutor(r ->
|
||||
new Thread(r, "mcp-health-checker")
|
||||
);
|
||||
long healthCheckIntervalMs = 10_000;
|
||||
this.healthChecker.scheduleAtFixedRate(
|
||||
this::performHealthCheck,
|
||||
healthCheckIntervalMs,
|
||||
healthCheckIntervalMs,
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(this::close, "mcp-shutdown-hook"));
|
||||
|
||||
autoLoadConfigFromResource();
|
||||
}
|
||||
|
||||
private void autoLoadConfigFromResource() {
|
||||
String resourcePath = System.getProperty(CONFIG_RESOURCE_PROPERTY, DEFAULT_CONFIG_RESOURCE);
|
||||
try (InputStream is = McpClientManager.class.getClassLoader().getResourceAsStream(resourcePath)) {
|
||||
if (is != null) {
|
||||
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
registerFromJson(json);
|
||||
log.info("Auto-loaded MCP configuration from: {}", resourcePath);
|
||||
} else {
|
||||
log.debug("MCP config resource not found (skipping auto-load): {}", resourcePath);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to auto-load MCP config from resource: " + resourcePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void reloadConfig() {
|
||||
McpClientManager manager = getInstance();
|
||||
// 先关闭所有现有 client
|
||||
manager.descriptorRegistry.values().forEach(McpClientDescriptor::close);
|
||||
manager.descriptorRegistry.clear();
|
||||
// 重新加载
|
||||
manager.autoLoadConfigFromResource();
|
||||
}
|
||||
|
||||
public static McpClientManager getInstance() {
|
||||
if (INSTANCE == null) {
|
||||
synchronized (McpClientManager.class) {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = new McpClientManager();
|
||||
}
|
||||
}
|
||||
}
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
|
||||
public void registerFromJson(String json) {
|
||||
McpConfig mcpConfig = JSON.parseObject(json, McpConfig.class);
|
||||
registerFromConfig(mcpConfig);
|
||||
}
|
||||
|
||||
public void registerFromFile(Path filePath) throws IOException {
|
||||
String json = Files.readString(filePath, StandardCharsets.UTF_8);
|
||||
registerFromJson(json);
|
||||
}
|
||||
|
||||
public void registerFromResource(String resourcePath) {
|
||||
try (InputStream is = McpClientManager.class.getClassLoader().getResourceAsStream(resourcePath)) {
|
||||
if (is == null) {
|
||||
throw new IllegalArgumentException("Resource not found: " + resourcePath);
|
||||
}
|
||||
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
registerFromJson(json);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to load MCP config from resource: " + resourcePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void registerFromConfig(McpConfig config) {
|
||||
if (config == null || config.getMcpServers() == null) {
|
||||
log.warn("MCP config is empty, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (Map.Entry<String, McpConfig.ServerSpec> entry : config.getMcpServers().entrySet()) {
|
||||
String name = entry.getKey();
|
||||
McpConfig.ServerSpec spec = entry.getValue();
|
||||
|
||||
if (descriptorRegistry.containsKey(name)) {
|
||||
try {
|
||||
McpClientDescriptor desc = descriptorRegistry.get(name);
|
||||
desc.close();
|
||||
} finally {
|
||||
descriptorRegistry.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> resolvedEnv = new HashMap<>();
|
||||
for (Map.Entry<String, String> envEntry : spec.getEnv().entrySet()) {
|
||||
String key = envEntry.getKey();
|
||||
String value = envEntry.getValue();
|
||||
if (value != null && value.startsWith("${input:") && value.endsWith("}")) {
|
||||
String inputId = value.substring("${input:".length(), value.length() - 1);
|
||||
value = System.getProperty("mcp.input." + inputId, "");
|
||||
}
|
||||
resolvedEnv.put(key, value);
|
||||
}
|
||||
|
||||
McpClientDescriptor descriptor = new McpClientDescriptor(name, spec, resolvedEnv);
|
||||
descriptorRegistry.put(name, descriptor);
|
||||
log.info("Registered MCP client: {} (transport: {})", name, spec.getTransport());
|
||||
}
|
||||
}
|
||||
|
||||
public McpSyncClient getMcpClient(String name) {
|
||||
McpClientDescriptor desc = descriptorRegistry.get(name);
|
||||
if (desc == null) {
|
||||
throw new IllegalArgumentException("MCP client not found: " + name);
|
||||
}
|
||||
return desc.getClient();
|
||||
}
|
||||
|
||||
public Tool getMcpTool(String name, String toolName) {
|
||||
McpClientDescriptor desc = descriptorRegistry.get(name);
|
||||
if (desc == null) {
|
||||
throw new IllegalArgumentException("MCP client not found: " + name);
|
||||
}
|
||||
return desc.getMcpTool(toolName);
|
||||
}
|
||||
|
||||
public boolean isClientOnline(String name) {
|
||||
McpClientDescriptor desc = descriptorRegistry.get(name);
|
||||
return desc != null && desc.isAlive();
|
||||
}
|
||||
|
||||
public void reconnect(String name) {
|
||||
McpClientDescriptor oldDesc = descriptorRegistry.get(name);
|
||||
if (oldDesc == null) return;
|
||||
|
||||
oldDesc.close();
|
||||
McpClientDescriptor newDesc = new McpClientDescriptor(
|
||||
oldDesc.getName(), oldDesc.getSpec(), oldDesc.getResolvedEnv()
|
||||
);
|
||||
descriptorRegistry.put(name, newDesc);
|
||||
log.info("Reconnected MCP client: {}", name);
|
||||
}
|
||||
|
||||
private void performHealthCheck() {
|
||||
for (McpClientDescriptor desc : descriptorRegistry.values()) {
|
||||
if (desc.isClosed()) continue;
|
||||
try {
|
||||
desc.pingIfNeeded();
|
||||
} catch (Exception e) {
|
||||
log.error("Health check error for client: " + desc.getName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
healthChecker.shutdown();
|
||||
try {
|
||||
if (!healthChecker.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
healthChecker.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
healthChecker.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
for (McpClientDescriptor desc : descriptorRegistry.values()) {
|
||||
try {
|
||||
desc.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error closing MCP client descriptor", e);
|
||||
}
|
||||
}
|
||||
descriptorRegistry.clear();
|
||||
log.info("McpClientManager closed.");
|
||||
}
|
||||
|
||||
public McpClientDescriptor getMcpClientDescriptor(String name) {
|
||||
return descriptorRegistry.get(name);
|
||||
}
|
||||
|
||||
public Map<String, McpClientDescriptor> getDescriptorRegistry() {
|
||||
return descriptorRegistry;
|
||||
}
|
||||
|
||||
public ScheduledExecutorService getHealthChecker() {
|
||||
return healthChecker;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* <p>
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* <p>
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class McpConfig {
|
||||
|
||||
private List<InputSpec> inputs = Collections.emptyList();
|
||||
private Map<String, ServerSpec> mcpServers = Collections.emptyMap();
|
||||
|
||||
public List<InputSpec> getInputs() {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
public void setInputs(List<InputSpec> inputs) {
|
||||
this.inputs = inputs;
|
||||
}
|
||||
|
||||
public Map<String, ServerSpec> getMcpServers() {
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
public void setMcpServers(Map<String, ServerSpec> mcpServers) {
|
||||
this.mcpServers = mcpServers;
|
||||
}
|
||||
|
||||
public static class InputSpec {
|
||||
private String type;
|
||||
private String id;
|
||||
private String description;
|
||||
|
||||
// getters
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ServerSpec {
|
||||
private String transport = "stdio"; // 新增
|
||||
private String command;
|
||||
private List<String> args;
|
||||
private Map<String, String> env = Collections.emptyMap();
|
||||
private String url; // 新增
|
||||
|
||||
public String getTransport() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
public void setTransport(String transport) {
|
||||
this.transport = transport;
|
||||
}
|
||||
|
||||
public String getCommand() {
|
||||
return command;
|
||||
}
|
||||
|
||||
public void setCommand(String command) {
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
public List<String> getArgs() {
|
||||
return args;
|
||||
}
|
||||
|
||||
public void setArgs(List<String> args) {
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
public Map<String, String> getEnv() {
|
||||
return env;
|
||||
}
|
||||
|
||||
public void setEnv(Map<String, String> env) {
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* <p>
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* <p>
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import com.easyagents.core.model.chat.tool.Parameter;
|
||||
import com.easyagents.core.model.chat.tool.Tool;
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class McpTool implements Tool {
|
||||
|
||||
final McpSyncClient mcpClient;
|
||||
final McpSchema.Tool mcpOriginalTool;
|
||||
|
||||
public McpTool(McpSyncClient mcpClient, McpSchema.Tool mcpOriginalTool) {
|
||||
this.mcpClient = mcpClient;
|
||||
this.mcpOriginalTool = mcpOriginalTool;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return mcpOriginalTool.name();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return mcpOriginalTool.description();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Parameter[] getParameters() {
|
||||
McpSchema.JsonSchema inputSchema = mcpOriginalTool.inputSchema();
|
||||
if (inputSchema == null) {
|
||||
return new Parameter[0];
|
||||
}
|
||||
|
||||
Map<String, Object> properties = inputSchema.properties();
|
||||
if (properties == null || properties.isEmpty()) {
|
||||
return new Parameter[0];
|
||||
}
|
||||
|
||||
List<String> required = inputSchema.required();
|
||||
if (required == null) required = Collections.emptyList();
|
||||
|
||||
Parameter[] parameters = new Parameter[properties.size()];
|
||||
int i = 0;
|
||||
for (Map.Entry<String, Object> entry : properties.entrySet()) {
|
||||
Parameter parameter = new Parameter();
|
||||
parameter.setName(entry.getKey());
|
||||
|
||||
//"type" -> "number"
|
||||
//"minimum" -> {Integer@3634} 1
|
||||
//"maximum" -> {Integer@3636} 10
|
||||
//"default" -> {Integer@3638} 3
|
||||
//"description" -> "Number of resource links to return (1-10)"
|
||||
//"enum" -> {ArrayList@3858} size = 3
|
||||
// key = "enum"
|
||||
// value = {ArrayList@3858} size = 3
|
||||
// 0 = "error"
|
||||
// 1 = "success"
|
||||
// 2 = "debug"
|
||||
//"additionalProperties" -> {LinkedHashMap@3759} size = 3
|
||||
// key = "additionalProperties"
|
||||
// value = {LinkedHashMap@3759} size = 3
|
||||
// "type" -> "string"
|
||||
// "format" -> "uri"
|
||||
// "description" -> "URL of the file to include in the zip"
|
||||
@SuppressWarnings("unchecked") Map<String, Object> entryValue = (Map<String, Object>) entry.getValue();
|
||||
|
||||
parameter.setType((String) entryValue.get("type"));
|
||||
parameter.setDescription((String) entryValue.get("description"));
|
||||
parameter.setDefaultValue(entryValue.get("default"));
|
||||
|
||||
if (required.contains(entry.getKey())) {
|
||||
parameter.setRequired(true);
|
||||
}
|
||||
|
||||
Object anEnum = entryValue.get("enum");
|
||||
if (anEnum instanceof Collection<?>) {
|
||||
parameter.setEnums(((Collection<?>) anEnum).toArray(new String[0]));
|
||||
}
|
||||
|
||||
parameters[i++] = parameter;
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object invoke(Map<String, Object> argsMap) {
|
||||
McpSchema.CallToolResult callToolResult;
|
||||
try {
|
||||
callToolResult = mcpClient.callTool(new McpSchema.CallToolRequest(mcpOriginalTool.name(), argsMap));
|
||||
} catch (Exception e) {
|
||||
throw new McpCallException("MCP Tool call exception, tool name: " + mcpOriginalTool.name(), e);
|
||||
}
|
||||
|
||||
if (callToolResult.isError() != null && callToolResult.isError()) {
|
||||
throw new McpCallException("MCP Tool call exception, tool name: " + mcpOriginalTool.name() + ", info: " + callToolResult.structuredContent());
|
||||
}
|
||||
|
||||
List<McpSchema.Content> content = callToolResult.content();
|
||||
if (content == null || content.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (content.size() == 1 && content.get(0) instanceof McpSchema.TextContent) {
|
||||
return ((McpSchema.TextContent) content.get(0)).text();
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* <p>
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* <p>
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface McpTransportFactory {
|
||||
CloseableTransport create(McpConfig.ServerSpec spec, Map<String, String> resolvedEnv);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* <p>
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* <p>
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.ServerParameters;
|
||||
import io.modelcontextprotocol.client.transport.StdioClientTransport;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class StdioTransportFactory implements McpTransportFactory {
|
||||
|
||||
@Override
|
||||
public CloseableTransport create(McpConfig.ServerSpec spec, Map<String, String> resolvedEnv) {
|
||||
// ProcessBuilder pb = new ProcessBuilder();
|
||||
// List<String> args = spec.getArgs();
|
||||
// if (args != null && !args.isEmpty()) {
|
||||
// pb.command(spec.getCommand(), args.toArray(new String[0]));
|
||||
// } else {
|
||||
// pb.command(spec.getCommand());
|
||||
// }
|
||||
// if (!resolvedEnv.isEmpty()) {
|
||||
// pb.environment().putAll(resolvedEnv);
|
||||
// }
|
||||
// pb.redirectErrorStream(true);
|
||||
|
||||
try {
|
||||
// Process process = pb.start();
|
||||
// OutputStream stdin = process.getOutputStream();
|
||||
// InputStream stdout = process.getInputStream();
|
||||
|
||||
// StdioClientTransport transport = new StdioClientTransport(
|
||||
// stdin, stdout, McpJsonMapper.getDefault(), () -> {}
|
||||
// );
|
||||
|
||||
|
||||
// ServerParameters params = ServerParameters.builder("npx")
|
||||
// .args("-y", "@modelcontextprotocol/server-everything")
|
||||
// .build();
|
||||
|
||||
|
||||
ServerParameters parameters = ServerParameters.builder(spec.getCommand())
|
||||
.args(spec.getArgs())
|
||||
.build();
|
||||
|
||||
StdioClientTransport transport = new StdioClientTransport(parameters, McpJsonMapper.getDefault());
|
||||
|
||||
|
||||
return new CloseableTransport() {
|
||||
@Override
|
||||
public McpClientTransport getTransport() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
transport.close();
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
}
|
||||
// if (process.isAlive()) {
|
||||
// process.destroy();
|
||||
// try {
|
||||
// if (!process.waitFor(3, TimeUnit.SECONDS)) {
|
||||
// process.destroyForcibly();
|
||||
// }
|
||||
// } catch (InterruptedException ex) {
|
||||
// Thread.currentThread().interrupt();
|
||||
// process.destroyForcibly();
|
||||
// }
|
||||
// }
|
||||
}
|
||||
};
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to start stdio process", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
easy-agents-mcp/src/main/resources/mcp-servers.json
Normal file
11
easy-agents-mcp/src/main/resources/mcp-servers.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"everything": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-everything"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import com.easyagents.core.message.ToolCall;
|
||||
import com.easyagents.core.model.chat.tool.Tool;
|
||||
import com.easyagents.core.model.chat.tool.ToolExecutor;
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* McpClientManager unit test class
|
||||
* McpClientManager单元测试类
|
||||
*/
|
||||
class McpClientManagerTest {
|
||||
|
||||
@Mock
|
||||
private McpClientDescriptor mockDescriptor;
|
||||
|
||||
@Mock
|
||||
private McpSyncClient mockClient;
|
||||
|
||||
@Mock
|
||||
private Tool mockTool;
|
||||
|
||||
private McpClientManager mcpClientManager;
|
||||
private AutoCloseable mockitoMocks;
|
||||
|
||||
// Test JSON configuration
|
||||
// 测试用JSON配置
|
||||
private static final String TEST_JSON_CONFIG = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"test-server": {
|
||||
"transport": "stdio",
|
||||
"env": {
|
||||
"TEST_VAR": "test_value",
|
||||
"INPUT_VAR": "${input:test_input}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 初始化Mockito mocks
|
||||
mockitoMocks = MockitoAnnotations.openMocks(this);
|
||||
|
||||
// Reset singleton instance before each test
|
||||
// 在每个测试前重置单例实例
|
||||
try {
|
||||
java.lang.reflect.Field instance = McpClientManager.class.getDeclaredField("INSTANCE");
|
||||
instance.setAccessible(true);
|
||||
instance.set(null, null);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
mcpClientManager = McpClientManager.getInstance();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
if (mcpClientManager != null) {
|
||||
mcpClientManager.close();
|
||||
}
|
||||
// 关闭Mockito mocks
|
||||
if (mockitoMocks != null) {
|
||||
mockitoMocks.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test singleton pattern - 单例模式测试")
|
||||
void testSingletonPattern() {
|
||||
McpClientManager instance1 = McpClientManager.getInstance();
|
||||
McpClientManager instance2 = McpClientManager.getInstance();
|
||||
|
||||
assertSame(instance1, instance2, "Should return same instance for singleton pattern");
|
||||
assertNotNull(instance1, "Instance should not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test registerFromJson - JSON配置注册测试")
|
||||
void testRegisterFromJson() {
|
||||
// Mock descriptor behavior
|
||||
// 模拟描述符行为
|
||||
when(mockDescriptor.getName()).thenReturn("test-server");
|
||||
when(mockDescriptor.isAlive()).thenReturn(true);
|
||||
when(mockDescriptor.getClient()).thenReturn(mockClient);
|
||||
when(mockDescriptor.getMcpTool("test-tool")).thenReturn(mockTool);
|
||||
|
||||
// Use reflection to replace descriptor in registry
|
||||
// 使用反射替换注册表中的描述符
|
||||
try {
|
||||
java.lang.reflect.Field registryField = McpClientManager.class.getDeclaredField("descriptorRegistry");
|
||||
registryField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.Map<String, McpClientDescriptor> registry =
|
||||
(java.util.Map<String, McpClientDescriptor>) registryField.get(mcpClientManager);
|
||||
|
||||
registry.put("test-server", mockDescriptor);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
// Test client retrieval
|
||||
// 测试客户端获取
|
||||
McpSyncClient client = mcpClientManager.getMcpClient("test-server");
|
||||
assertNotNull(client, "Client should not be null");
|
||||
assertSame(mockClient, client, "Should return the mocked client");
|
||||
|
||||
// Test tool retrieval
|
||||
// 测试工具获取
|
||||
Tool tool = mcpClientManager.getMcpTool("test-server", "test-tool");
|
||||
assertNotNull(tool, "Tool should not be null");
|
||||
assertSame(mockTool, tool, "Should return the mocked tool");
|
||||
|
||||
// Test online status
|
||||
// 测试在线状态
|
||||
assertTrue(mcpClientManager.isClientOnline("test-server"), "Client should be online");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test getClient with non-existent client - 获取不存在客户端测试")
|
||||
void testGetMcpClientNonExistent() {
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
mcpClientManager.getMcpClient("non-existent-server");
|
||||
}, "Should throw IllegalArgumentException for non-existent client");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test getMcpTool with non-existent client - 获取不存在客户端工具测试")
|
||||
void testGetMcpToolNonExistent() {
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
mcpClientManager.getMcpTool("non-existent-server", "test-tool");
|
||||
}, "Should throw IllegalArgumentException for non-existent client");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test isClientOnline with non-existent client - 检查不存在客户端在线状态测试")
|
||||
void testIsClientOnlineNonExistent() {
|
||||
assertFalse(mcpClientManager.isClientOnline("non-existent-server"),
|
||||
"Should return false for non-existent client");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test registerFromFile - 文件配置注册测试")
|
||||
void testRegisterFromFile() throws IOException {
|
||||
// Create a temporary file with test config
|
||||
// 创建包含测试配置的临时文件
|
||||
Path tempFile = Files.createTempFile("test-config", ".json");
|
||||
Files.write(tempFile, TEST_JSON_CONFIG.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertDoesNotThrow(() -> {
|
||||
mcpClientManager.registerFromFile(tempFile);
|
||||
}, "Should not throw exception when registering from file");
|
||||
|
||||
// Clean up
|
||||
// 清理
|
||||
Files.deleteIfExists(tempFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test registerFromFile IOException - 文件配置注册IO异常测试")
|
||||
void testRegisterFromFileIOException() {
|
||||
Path nonExistentPath = Path.of("/non/existent/path.json");
|
||||
|
||||
assertThrows(IOException.class, () -> {
|
||||
mcpClientManager.registerFromFile(nonExistentPath);
|
||||
}, "Should throw IOException for non-existent file");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test registerFromResource - 资源配置注册测试")
|
||||
void testRegisterFromResource() {
|
||||
String resourcePath = "mcp-servers.json";
|
||||
assertDoesNotThrow(() -> {
|
||||
mcpClientManager.registerFromResource(resourcePath);
|
||||
}, "Should not throw exception when registering from resource");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test registerFromResource with non-existent resource - 注册不存在资源测试")
|
||||
void testRegisterFromResourceNonExistent() {
|
||||
String resourcePath = "non-existent-config.json";
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
mcpClientManager.registerFromResource(resourcePath);
|
||||
}, "Should throw IllegalArgumentException for non-existent resource");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test reconnect functionality - 重新连接功能测试")
|
||||
void testReconnect() {
|
||||
// First register a descriptor
|
||||
// 首先注册一个描述符
|
||||
try {
|
||||
java.lang.reflect.Field registryField = McpClientManager.class.getDeclaredField("descriptorRegistry");
|
||||
registryField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.Map<String, McpClientDescriptor> registry =
|
||||
(java.util.Map<String, McpClientDescriptor>) registryField.get(mcpClientManager);
|
||||
|
||||
registry.put("test-server", mockDescriptor);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
// Mock close behavior
|
||||
// 模拟关闭行为
|
||||
doNothing().when(mockDescriptor).close();
|
||||
when(mockDescriptor.getName()).thenReturn("test-server");
|
||||
when(mockDescriptor.getSpec()).thenReturn(new McpConfig().getMcpServers().get("test-server"));
|
||||
when(mockDescriptor.getResolvedEnv()).thenReturn(new HashMap<>());
|
||||
|
||||
assertDoesNotThrow(() -> {
|
||||
mcpClientManager.reconnect("test-server");
|
||||
}, "Reconnect should not throw exception");
|
||||
|
||||
// Verify close was called
|
||||
// 验证关闭被调用
|
||||
verify(mockDescriptor, times(1)).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test reconnect with non-existent client - 重新连接不存在客户端测试")
|
||||
void testReconnectNonExistent() {
|
||||
assertDoesNotThrow(() -> {
|
||||
mcpClientManager.reconnect("non-existent-server");
|
||||
}, "Reconnect should not throw exception for non-existent client");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test reloadConfig - 重新加载配置测试")
|
||||
void testReloadConfig() throws IllegalAccessException, NoSuchFieldException {
|
||||
// Mock the autoLoadConfigFromResource method
|
||||
// 模拟autoLoadConfigFromResource方法
|
||||
try (MockedStatic<McpClientManager> managerMock = Mockito.mockStatic(McpClientManager.class,
|
||||
Mockito.CALLS_REAL_METHODS)) {
|
||||
|
||||
// Add a descriptor first
|
||||
// 首先添加一个描述符
|
||||
java.lang.reflect.Field registryField = McpClientManager.class.getDeclaredField("descriptorRegistry");
|
||||
registryField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.Map<String, McpClientDescriptor> registry =
|
||||
(java.util.Map<String, McpClientDescriptor>) registryField.get(mcpClientManager);
|
||||
|
||||
registry.put("test-server", mockDescriptor);
|
||||
|
||||
// Mock close behavior
|
||||
// 模拟关闭行为
|
||||
doNothing().when(mockDescriptor).close();
|
||||
|
||||
assertDoesNotThrow(() -> {
|
||||
McpClientManager.reloadConfig();
|
||||
}, "Reload config should not throw exception");
|
||||
|
||||
// Verify that existing descriptors were closed
|
||||
// 验证现有描述符被关闭
|
||||
verify(mockDescriptor, times(1)).close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test close functionality - 关闭功能测试")
|
||||
void testClose() {
|
||||
assertDoesNotThrow(() -> {
|
||||
mcpClientManager.close();
|
||||
}, "Close should not throw exception");
|
||||
|
||||
// Verify that the manager can be closed multiple times safely
|
||||
// 验证管理器可以安全地多次关闭
|
||||
assertDoesNotThrow(() -> {
|
||||
mcpClientManager.close();
|
||||
}, "Second close should not throw exception");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test environment variable resolution - 环境变量解析测试")
|
||||
void testEnvironmentVariableResolution() {
|
||||
// Test the registerFromConfig method with environment variable resolution
|
||||
// 测试带有环境变量解析的registerFromConfig方法
|
||||
String jsonWithInputVar = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"test-server": {
|
||||
"transport": "stdio",
|
||||
"env": {
|
||||
"INPUT_VAR": "${input:test_input}",
|
||||
"NORMAL_VAR": "normal_value"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Set system property for input resolution
|
||||
// 设置输入解析的系统属性
|
||||
System.setProperty("mcp.input.test_input", "resolved_value");
|
||||
|
||||
try {
|
||||
mcpClientManager.registerFromJson(jsonWithInputVar);
|
||||
} finally {
|
||||
// Clean up system property
|
||||
// 清理系统属性
|
||||
System.clearProperty("mcp.input.test_input");
|
||||
}
|
||||
|
||||
// The registration should succeed without throwing exception
|
||||
// 注册应该成功而不抛出异常
|
||||
assertDoesNotThrow(() -> {
|
||||
mcpClientManager.isClientOnline("test-server");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test JSON parsing error - JSON解析错误测试")
|
||||
void testJsonParsingError() {
|
||||
String invalidJson = "{ invalid json }";
|
||||
|
||||
assertThrows(Exception.class, () -> {
|
||||
mcpClientManager.registerFromJson(invalidJson);
|
||||
}, "Should throw exception for invalid JSON");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Test duplicate registration - 重复注册测试")
|
||||
void testDuplicateRegistration() {
|
||||
// Register the same server twice
|
||||
// 重复注册同一个服务器
|
||||
try {
|
||||
java.lang.reflect.Field registryField = McpClientManager.class.getDeclaredField("descriptorRegistry");
|
||||
registryField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.Map<String, McpClientDescriptor> registry =
|
||||
(java.util.Map<String, McpClientDescriptor>) registryField.get(mcpClientManager);
|
||||
|
||||
registry.put("duplicate-server", mockDescriptor);
|
||||
when(mockDescriptor.getName()).thenReturn("duplicate-server");
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
// The second registration should be skipped (no exception thrown)
|
||||
// 第二次注册应该被跳过(不抛出异常)
|
||||
assertDoesNotThrow(() -> {
|
||||
mcpClientManager.registerFromJson(TEST_JSON_CONFIG);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@DisplayName("Test call tool - 测试工具调用")
|
||||
void testCallTool() {
|
||||
|
||||
// The second registration should be skipped (no exception thrown)
|
||||
// 第二次注册应该被跳过(不抛出异常)
|
||||
assertDoesNotThrow(() -> {
|
||||
mcpClientManager.registerFromResource("mcp-servers.json");
|
||||
});
|
||||
|
||||
|
||||
Tool mcpTool = mcpClientManager.getMcpTool("everything", "add");
|
||||
|
||||
if (mcpTool == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(mcpTool);
|
||||
|
||||
ToolExecutor toolExecutor = new ToolExecutor(mcpTool
|
||||
, new ToolCall("add", "add", "{\"a\":1,\"b\":2}"));
|
||||
|
||||
Object result = toolExecutor.execute();
|
||||
|
||||
assertEquals("The sum of 1 and 2 is 3.", result);
|
||||
|
||||
System.out.println(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user