初始化

This commit is contained in:
2026-02-22 18:55:40 +08:00
commit 8392cdd861
496 changed files with 45020 additions and 0 deletions

66
easy-agents-mcp/pom.xml Normal file
View 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>

View File

@@ -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();
}

View File

@@ -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
}
}
};
}
}

View File

@@ -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
}
}
};
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"everything": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-everything"
]
}
}
}

View File

@@ -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);
}
}