diff --git a/core/pom.xml b/core/pom.xml index 69c39b47e..5c8d6ee0c 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -263,6 +263,12 @@ provided true + + + com.taobao.arthas + arthas-mcp-server + ${project.version} + diff --git a/core/src/main/java/com/taobao/arthas/core/command/CommandExecutorImpl.java b/core/src/main/java/com/taobao/arthas/core/command/CommandExecutorImpl.java new file mode 100644 index 000000000..0bd48d333 --- /dev/null +++ b/core/src/main/java/com/taobao/arthas/core/command/CommandExecutorImpl.java @@ -0,0 +1,319 @@ +package com.taobao.arthas.core.command; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONWriter; +import com.taobao.arthas.core.command.model.ResultModel; +import com.taobao.arthas.core.distribution.impl.PackingResultDistributorImpl; +import com.taobao.arthas.core.shell.cli.CliToken; +import com.taobao.arthas.core.shell.cli.CliTokens; +import com.taobao.arthas.core.shell.cli.Completion; +import com.taobao.arthas.core.shell.handlers.Handler; +import com.taobao.arthas.core.shell.session.Session; +import com.taobao.arthas.core.shell.session.SessionManager; +import com.taobao.arthas.core.shell.system.Job; +import com.taobao.arthas.core.shell.system.JobController; +import com.taobao.arthas.core.shell.system.JobListener; +import com.taobao.arthas.core.shell.system.impl.InternalCommandManager; +import com.taobao.arthas.core.shell.term.SignalHandler; +import com.taobao.arthas.core.shell.term.Term; +import com.taobao.arthas.mcp.server.CommandExecutor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * 命令执行器,用于执行Arthas命令 + */ +public class CommandExecutorImpl implements CommandExecutor { + private static final Logger logger = LoggerFactory.getLogger(CommandExecutorImpl.class); + + private final SessionManager sessionManager; + private final JobController jobController; + private final InternalCommandManager commandManager; + + public CommandExecutorImpl(SessionManager sessionManager) { + this.sessionManager = sessionManager; + this.commandManager = sessionManager.getCommandManager(); + this.jobController = sessionManager.getJobController(); + } + + /** + * 同步执行命令 + * @param commandLine 命令行 + * @param timeout 超时时间(毫秒) + * @return 命令执行结果 + */ + @Override + public Map execute(String commandLine, long timeout) { + if (commandLine == null || commandLine.trim().isEmpty()) { + logger.error("Command line is null or empty"); + return createErrorResult(commandLine, "Command line is null or empty"); + } + + List tokens = CliTokens.tokenize(commandLine); + if (tokens.isEmpty()) { + logger.error("No command found in command line: {}", commandLine); + return createErrorResult(commandLine, "No command found in command line"); + } + + Map result = new TreeMap<>(); + Session session = null; + PackingResultDistributorImpl resultDistributor = null; + Job job = null; + + try { + session = sessionManager.createSession(); + if (session == null) { + logger.error("Failed to create session for command: {}", commandLine); + return createErrorResult(commandLine, "Failed to create session"); + } + resultDistributor = new PackingResultDistributorImpl(session); + InMemoryTerm term = new InMemoryTerm(); + term.setSession(session); + + job = jobController.createJob(commandManager, tokens, session, + new JobHandle(), term, resultDistributor); + + if (job == null) { + logger.error("Failed to create job for command: {}", commandLine); + return createErrorResult(commandLine, "Failed to create job"); + } + + job.run(); + + boolean finished = waitForJob(job, (int) timeout); + if (!finished) { + logger.warn("Command timeout after {} ms: {}", timeout, commandLine); + job.interrupt(); + return createTimeoutResult(commandLine, timeout); + } + + result.put("command", commandLine); + result.put("success", true); + result.put("executionTime", System.currentTimeMillis()); + List results = resultDistributor.getResults(); + + if (results != null && !results.isEmpty()) { + result.put("results", results); + result.put("resultCount", results.size()); + } else { + result.put("results", results); + result.put("resultCount", 0); + } + String termOutput = term.getOutput(); + if (termOutput != null && !termOutput.trim().isEmpty()) { + result.put("terminalOutput", termOutput); + } + + logger.info("Command executed successfully: {}", commandLine); + return result; + + } catch (Exception e) { + logger.error("Error executing command: {}", commandLine, e); + return createErrorResult(commandLine, "Error executing command: " + e.getMessage()); + } finally { + if (session != null) { + try { + sessionManager.removeSession(session.getSessionId()); + } catch (Exception e) { + logger.warn("Error removing session", e); + } + } + } + } + + private boolean waitForJob(Job job, int timeout) { + long startTime = System.currentTimeMillis(); + while (true) { + switch (job.status()) { + case STOPPED: + case TERMINATED: + return true; + } + if (System.currentTimeMillis() - startTime > timeout) { + return false; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + } + } + } + + private Map createErrorResult(String commandLine, String errorMessage) { + Map result = new TreeMap<>(); + result.put("command", commandLine); + result.put("success", false); + result.put("error", errorMessage); + result.put("executionTime", System.currentTimeMillis()); + return result; + } + + /** + * 创建超时结果 + */ + private Map createTimeoutResult(String commandLine, long timeout) { + Map result = new TreeMap<>(); + result.put("command", commandLine); + result.put("success", false); + result.put("error", "Command timeout after " + timeout + " ms"); + result.put("timeout", true); + result.put("executionTime", System.currentTimeMillis()); + return result; + } + + private static class JobHandle implements JobListener { + private final CountDownLatch latch = new CountDownLatch(1); + + @Override + public void onForeground(Job job) { + } + + @Override + public void onBackground(Job job) { + } + + @Override + public void onTerminated(Job job) { + latch.countDown(); + } + + @Override + public void onSuspend(Job job) { + } + + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return latch.await(timeout, unit); + } + } + + public static class InMemoryTerm implements Term { + private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + private Session session; + private volatile boolean closed = false; + + @Override + public Term setSession(Session session) { + this.session = session; + return this; + } + + public Session getSession() { + return session; + } + + @Override + public void readline(String prompt, Handler lineHandler) { + } + + @Override + public void readline(String prompt, Handler lineHandler, Handler completionHandler) { + } + + @Override + public Term closeHandler(Handler handler) { + return this; + } + + @Override + public long lastAccessedTime() { + return System.currentTimeMillis(); + } + + @Override + public String type() { + return "inmemory"; + } + + @Override + public int width() { + return 120; + } + + @Override + public int height() { + return 40; + } + + @Override + public Term resizehandler(Handler handler) { + return this; + } + + @Override + public Term stdinHandler(Handler handler) { + return this; + } + + @Override + public Term stdoutHandler(io.termd.core.function.Function handler) { + return this; + } + + @Override + public synchronized Term write(String data) { + if (closed) { + return this; + } + + try { + if (data != null) { + outputStream.write(data.getBytes(StandardCharsets.UTF_8)); + } + } catch (Exception e) { + logger.error("Error writing to terminal", e); + } + return this; + } + + @Override + public Term interruptHandler(SignalHandler handler) { + return this; + } + + @Override + public Term suspendHandler(SignalHandler handler) { + return this; + } + + @Override + public synchronized void close() { + closed = true; + try { + outputStream.close(); + } catch (Exception e) { + logger.error("Error closing output stream", e); + } + } + + @Override + public Term echo(String text) { + return write(text); + } + + public synchronized String getOutput() { + try { + return outputStream.toString(StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + logger.error("Error getting output", e); + return ""; + } + } + + public synchronized void clearOutput() { + outputStream.reset(); + } + + public boolean isClosed() { + return closed; + } + } +} diff --git a/core/src/main/java/com/taobao/arthas/core/server/ArthasBootstrap.java b/core/src/main/java/com/taobao/arthas/core/server/ArthasBootstrap.java index f1e7fd533..8ee491a74 100644 --- a/core/src/main/java/com/taobao/arthas/core/server/ArthasBootstrap.java +++ b/core/src/main/java/com/taobao/arthas/core/server/ArthasBootstrap.java @@ -43,6 +43,7 @@ import com.taobao.arthas.common.SocketUtils; import com.taobao.arthas.core.advisor.Enhancer; import com.taobao.arthas.core.advisor.TransformerManager; import com.taobao.arthas.core.command.BuiltinCommandPack; +import com.taobao.arthas.core.command.CommandExecutorImpl; import com.taobao.arthas.core.command.view.ResultViewResolver; import com.taobao.arthas.core.config.BinderUtils; import com.taobao.arthas.core.config.Configure; @@ -77,6 +78,10 @@ import com.taobao.arthas.core.util.UserStatUtil; import com.taobao.arthas.core.util.affect.EnhancerAffect; import com.taobao.arthas.core.util.matcher.WildcardMatcher; +import com.taobao.arthas.mcp.server.ArthasMcpBootstrap; +import com.taobao.arthas.mcp.server.CommandExecutor; +import com.taobao.arthas.mcp.server.protocol.server.handler.McpRequestHandler; +import com.taobao.arthas.mcp.server.protocol.spec.McpServerTransportProvider; import io.netty.channel.ChannelFuture; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.util.concurrent.DefaultThreadFactory; @@ -125,6 +130,8 @@ public class ArthasBootstrap { private HttpApiHandler httpApiHandler; + private McpRequestHandler mcpRequestHandler; + private HttpSessionManager httpSessionManager; private SecurityAuthenticator securityAuthenticator; @@ -462,6 +469,11 @@ public class ArthasBootstrap { //http api handler httpApiHandler = new HttpApiHandler(historyManager, sessionManager); + // Mcp Server + CommandExecutor commandExecutor = new CommandExecutorImpl(sessionManager); + ArthasMcpBootstrap arthasMcpBootstrap = new ArthasMcpBootstrap(commandExecutor); + this.mcpRequestHandler = arthasMcpBootstrap.start().getMcpRequestHandler(); + logger().info("as-server listening on network={};telnet={};http={};timeout={};", configure.getIp(), configure.getTelnetPort(), configure.getHttpPort(), options.getConnectionTimeout()); @@ -671,6 +683,10 @@ public class ArthasBootstrap { return httpApiHandler; } + public McpRequestHandler getMcpRequestHandler() { + return mcpRequestHandler; + } + public File getOutputPath() { return outputPath; } diff --git a/core/src/main/java/com/taobao/arthas/core/shell/term/impl/http/HttpRequestHandler.java b/core/src/main/java/com/taobao/arthas/core/shell/term/impl/http/HttpRequestHandler.java index abae1af02..84c27cac7 100644 --- a/core/src/main/java/com/taobao/arthas/core/shell/term/impl/http/HttpRequestHandler.java +++ b/core/src/main/java/com/taobao/arthas/core/shell/term/impl/http/HttpRequestHandler.java @@ -5,6 +5,7 @@ import com.alibaba.arthas.deps.org.slf4j.LoggerFactory; import com.taobao.arthas.common.IOUtils; import com.taobao.arthas.core.server.ArthasBootstrap; import com.taobao.arthas.core.shell.term.impl.http.api.HttpApiHandler; +import com.taobao.arthas.mcp.server.protocol.server.handler.McpRequestHandler; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; @@ -37,6 +38,7 @@ public class HttpRequestHandler extends SimpleChannelInboundHandler + + 4.0.0 + + + arthas-all + com.taobao.arthas + ${revision} + ../../pom.xml + + + arthas-mcp-server + arthas-mcp-server + https://github.com/alibaba/arthas + + + 8 + 8 + UTF-8 + + + + + + + io.netty + netty-all + ${netty.version} + + + + + com.fasterxml.jackson.core + jackson-core + 2.18.1 + + + com.fasterxml.jackson.core + jackson-databind + 2.18.1 + + + + + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + arthas-mcp-server + + + src/main/resources + false + + **/* + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 8 + 8 + UTF-8 + true + + + + + + \ No newline at end of file diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/ArthasMcpBootstrap.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/ArthasMcpBootstrap.java new file mode 100644 index 000000000..dc45ab6a7 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/ArthasMcpBootstrap.java @@ -0,0 +1,49 @@ +package com.taobao.arthas.mcp.server; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Arthas MCP Bootstrap class + * + * @author Yeaury + */ +public class ArthasMcpBootstrap { + private static final Logger logger = LoggerFactory.getLogger(ArthasMcpBootstrap.class); + + private ArthasMcpServer mcpServer; + private final CommandExecutor commandExecutor; + private static ArthasMcpBootstrap instance; + + public ArthasMcpBootstrap(CommandExecutor commandExecutor) { + this.commandExecutor = commandExecutor; + instance = this; + } + + public static ArthasMcpBootstrap getInstance() { + return instance; + } + + public CommandExecutor getCommandExecutor() { + return commandExecutor; + } + + public ArthasMcpServer start() { + try { + // Create and start MCP server + mcpServer = new ArthasMcpServer(); + mcpServer.start(); + logger.info("Arthas MCP server initialized successfully"); + return mcpServer; + } catch (Exception e) { + logger.error("Failed to initialize Arthas MCP server", e); + throw new RuntimeException("Failed to initialize Arthas MCP server", e); + } + } + + public void shutdown() { + if (mcpServer != null) { + mcpServer.stop(); + } + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/ArthasMcpServer.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/ArthasMcpServer.java new file mode 100644 index 000000000..212f9aaf5 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/ArthasMcpServer.java @@ -0,0 +1,141 @@ +package com.taobao.arthas.mcp.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.taobao.arthas.mcp.server.protocol.config.McpServerProperties; +import com.taobao.arthas.mcp.server.protocol.server.McpNettyServer; +import com.taobao.arthas.mcp.server.protocol.server.McpServer; +import com.taobao.arthas.mcp.server.protocol.server.handler.McpRequestHandler; +import com.taobao.arthas.mcp.server.protocol.server.transport.HttpNettyServerTransportProvider; +import com.taobao.arthas.mcp.server.protocol.spec.McpSchema.*; +import com.taobao.arthas.mcp.server.protocol.spec.McpServerTransportProvider; +import com.taobao.arthas.mcp.server.tool.DefaultToolCallbackProvider; +import com.taobao.arthas.mcp.server.tool.ToolCallback; +import com.taobao.arthas.mcp.server.tool.ToolCallbackProvider; +import com.taobao.arthas.mcp.server.tool.util.McpToolUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Arthas MCP Server + * Used to expose HTTP service after Arthas startup + */ +public class ArthasMcpServer { + private static final Logger logger = LoggerFactory.getLogger(ArthasMcpServer.class); + private McpNettyServer server; + private final int port; + private final String bindAddress; + private McpRequestHandler mcpRequestHandler; + + public ArthasMcpServer() { + this(8080, "localhost"); + } + + public ArthasMcpServer(int port, String bindAddress) { + this.port = port; + this.bindAddress = bindAddress; + } + + public McpRequestHandler getMcpRequestHandler() { + return mcpRequestHandler; + } + + /** + * Start MCP server + */ + public void start() { + try { + // 1. Create server configuration + McpServerProperties properties = new McpServerProperties.Builder() + .name("arthas-mcp-server") + .version("1.0.0") + .bindAddress(bindAddress) + .port(port) + .messageEndpoint("/sse/message") + .sseEndpoint("/sse") + .toolChangeNotification(true) + .resourceChangeNotification(true) + .promptChangeNotification(true) + .objectMapper(new ObjectMapper()) + .build(); + + // 2. Create transport provider + McpServerTransportProvider transportProvider = createHttpTransportProvider(properties); + mcpRequestHandler = transportProvider.getMcpRequestHandler(); + + // 3. Create server builder + McpServer.NettySpecification serverBuilder = McpServer.netty(transportProvider) + .serverInfo(new Implementation(properties.getName(), properties.getVersion())) + .capabilities(buildServerCapabilities(properties)) + .instructions(properties.getInstructions()) + .requestTimeout(properties.getRequestTimeout()) + .objectMapper(properties.getObjectMapper() != null ? properties.getObjectMapper() : new ObjectMapper()); + + ToolCallbackProvider toolCallbackProvider = new DefaultToolCallbackProvider(); + ToolCallback[] callbacks = toolCallbackProvider.getToolCallbacks(); + List providerToolCallbacks = Arrays.stream(callbacks) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + serverBuilder.tools( + McpToolUtils.toToolSpecifications(providerToolCallbacks, properties)); + server = serverBuilder.build(); + + logger.info("Arthas MCP server started, listening on {}:{}", bindAddress, port); + } catch (Exception e) { + logger.error("Failed to start Arthas MCP server", e); + throw new RuntimeException("Failed to start Arthas MCP server", e); + } + } + + /** + * Create HTTP transport provider + */ + private HttpNettyServerTransportProvider createHttpTransportProvider(McpServerProperties properties) { + return HttpNettyServerTransportProvider.builder() + .messageEndpoint(properties.getMessageEndpoint()) + .sseEndpoint(properties.getSseEndpoint()) + .objectMapper(properties.getObjectMapper() != null ? properties.getObjectMapper() : new ObjectMapper()) + .build(); + } + + /** + * Build server capabilities configuration + */ + private ServerCapabilities buildServerCapabilities(McpServerProperties properties) { + return ServerCapabilities.builder() + .prompts(new ServerCapabilities.PromptCapabilities(properties.isPromptChangeNotification())) + .resources(new ServerCapabilities.ResourceCapabilities(properties.isResourceSubscribe(), properties.isResourceChangeNotification())) + .tools(new ServerCapabilities.ToolCapabilities(properties.isToolChangeNotification())) + .build(); + } + + /** + * Stop MCP server + */ + public void stop() { + if (server != null) { + try { + server.closeGracefully().get(); + logger.info("Arthas MCP server stopped"); + } catch (Exception e) { + logger.error("Failed to stop Arthas MCP server", e); + } + } + } + + public static void main(String[] args) { + ArthasMcpServer arthasMcpServer = new ArthasMcpServer(); + arthasMcpServer.start(); + // Keep the server running + try { + Thread.sleep(Long.MAX_VALUE); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/CommandExecutor.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/CommandExecutor.java new file mode 100644 index 000000000..c36c2d483 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/CommandExecutor.java @@ -0,0 +1,9 @@ +package com.taobao.arthas.mcp.server; + +import java.util.Map; + + +public interface CommandExecutor { + + Map execute(String commandLine, long timeout); +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/config/McpServerProperties.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/config/McpServerProperties.java new file mode 100644 index 000000000..94b856dd2 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/config/McpServerProperties.java @@ -0,0 +1,289 @@ +package com.taobao.arthas.mcp.server.protocol.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.taobao.arthas.mcp.server.protocol.server.handler.McpRequestHandler; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +/** + * MCP Server Configuration Properties + * Used to manage all configuration items for MCP server. + * + * @author Yeaury + */ +public class McpServerProperties { + + /** + * Server basic information + */ + private final String name; + private final String version; + private final String instructions; + + /** + * Server capability configuration + */ + private final boolean toolChangeNotification; + private final boolean resourceChangeNotification; + private final boolean promptChangeNotification; + private final boolean resourceSubscribe; + + /** + * Transport layer configuration + */ + private final String bindAddress; + private final int port; + private final String messageEndpoint; + private final String sseEndpoint; + + /** + * Timeout configuration + */ + private final Duration requestTimeout; + private final Duration initializationTimeout; + + private final ObjectMapper objectMapper; + + /** + * Private constructor, can only be created through Builder + */ + private McpServerProperties(Builder builder) { + this.name = builder.name; + this.version = builder.version; + this.instructions = builder.instructions; + this.toolChangeNotification = builder.toolChangeNotification; + this.resourceChangeNotification = builder.resourceChangeNotification; + this.promptChangeNotification = builder.promptChangeNotification; + this.resourceSubscribe = builder.resourceSubscribe; + this.bindAddress = builder.bindAddress; + this.port = builder.port; + this.messageEndpoint = builder.messageEndpoint; + this.sseEndpoint = builder.sseEndpoint; + this.requestTimeout = builder.requestTimeout; + this.initializationTimeout = builder.initializationTimeout; + this.objectMapper = builder.objectMapper; + } + + /** + * Create Builder with default configuration + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Get server name + * @return Server name + */ + public String getName() { + return name; + } + + /** + * Get server version + * @return Server version + */ + public String getVersion() { + return version; + } + + /** + * Get server instructions + * @return Server instructions + */ + public String getInstructions() { + return instructions; + } + + /** + * Get tool change notification + * @return Tool change notification + */ + public boolean isToolChangeNotification() { + return toolChangeNotification; + } + + /** + * Get resource change notification + * @return Resource change notification + */ + public boolean isResourceChangeNotification() { + return resourceChangeNotification; + } + + /** + * Get prompt change notification + * @return Prompt change notification + */ + public boolean isPromptChangeNotification() { + return promptChangeNotification; + } + + /** + * Get resource subscribe + * @return Resource subscribe + */ + public boolean isResourceSubscribe() { + return resourceSubscribe; + } + + /** + * Get bind address + * @return Bind address + */ + public String getBindAddress() { + return bindAddress; + } + + /** + * Get server port + * @return Server port + */ + public int getPort() { + return port; + } + + /** + * Get message endpoint + * @return Message endpoint + */ + public String getMessageEndpoint() { + return messageEndpoint; + } + + /** + * Get SSE endpoint + * @return SSE endpoint + */ + public String getSseEndpoint() { + return sseEndpoint; + } + + /** + * Get request timeout + * @return Request timeout + */ + public Duration getRequestTimeout() { + return requestTimeout; + } + + /** + * Get initialization timeout + * @return Initialization timeout + */ + public Duration getInitializationTimeout() { + return initializationTimeout; + } + + /** + * Get object mapper + * @return Object mapper + */ + public ObjectMapper getObjectMapper() { + return objectMapper; + } + + /** + * Builder class for McpServerProperties + */ + public static class Builder { + // Default values + private String name = "mcp-server"; + private String version = "1.0.0"; + private String instructions; + private boolean toolChangeNotification = true; + private boolean resourceChangeNotification = true; + private boolean promptChangeNotification = true; + private boolean resourceSubscribe = false; + private String bindAddress = "localhost"; + private int port = 8080; + private String messageEndpoint = "/mcp/message"; + private String sseEndpoint = "/mcp/sse"; + private Duration requestTimeout = Duration.ofSeconds(10); + private Duration initializationTimeout = Duration.ofSeconds(30); + private ObjectMapper objectMapper; + + public Builder() { + // Private constructor to prevent direct instantiation + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder version(String version) { + this.version = version; + return this; + } + + public Builder instructions(String instructions) { + this.instructions = instructions; + return this; + } + + public Builder toolChangeNotification(boolean toolChangeNotification) { + this.toolChangeNotification = toolChangeNotification; + return this; + } + + public Builder resourceChangeNotification(boolean resourceChangeNotification) { + this.resourceChangeNotification = resourceChangeNotification; + return this; + } + + public Builder promptChangeNotification(boolean promptChangeNotification) { + this.promptChangeNotification = promptChangeNotification; + return this; + } + + public Builder resourceSubscribe(boolean resourceSubscribe) { + this.resourceSubscribe = resourceSubscribe; + return this; + } + + public Builder bindAddress(String bindAddress) { + this.bindAddress = bindAddress; + return this; + } + + public Builder port(int port) { + this.port = port; + return this; + } + + public Builder messageEndpoint(String messageEndpoint) { + this.messageEndpoint = messageEndpoint; + return this; + } + + public Builder sseEndpoint(String sseEndpoint) { + this.sseEndpoint = sseEndpoint; + return this; + } + + public Builder requestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + + public Builder initializationTimeout(Duration initializationTimeout) { + this.initializationTimeout = initializationTimeout; + return this; + } + + public Builder objectMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + return this; + } + + /** + * Build McpServerProperties instance + */ + public McpServerProperties build() { + return new McpServerProperties(this); + } + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpNettyServer.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpNettyServer.java new file mode 100644 index 000000000..a2c5205bf --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpNettyServer.java @@ -0,0 +1,541 @@ +package com.taobao.arthas.mcp.server.protocol.server; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiFunction; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.netty.channel.Channel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.taobao.arthas.mcp.server.protocol.spec.*; +import com.taobao.arthas.mcp.server.util.Assert; +import com.taobao.arthas.mcp.server.util.Utils; + +/** + * A Netty-based MCP server implementation that provides access to tools, resources, and prompts. + * + * @author Yeaury + */ +public class McpNettyServer { + + private static final Logger logger = LoggerFactory.getLogger(McpNettyServer.class); + + private final McpServerTransportProvider mcpTransportProvider; + + private final ObjectMapper objectMapper; + + private final McpSchema.ServerCapabilities serverCapabilities; + + private final McpSchema.Implementation serverInfo; + + private final String instructions; + + private final CopyOnWriteArrayList tools = new CopyOnWriteArrayList<>(); + + private final CopyOnWriteArrayList resourceTemplates = new CopyOnWriteArrayList<>(); + + private final ConcurrentHashMap resources = new ConcurrentHashMap<>(); + + private final ConcurrentHashMap prompts = new ConcurrentHashMap<>(); + + private McpSchema.LoggingLevel minLoggingLevel = McpSchema.LoggingLevel.DEBUG; + + private List protocolVersions = Collections.singletonList(McpSchema.LATEST_PROTOCOL_VERSION); + + public McpNettyServer( + McpServerTransportProvider mcpTransportProvider, + ObjectMapper objectMapper, + Duration requestTimeout, + McpServerFeatures.McpServerConfig features) { + this.mcpTransportProvider = mcpTransportProvider; + this.objectMapper = objectMapper; + this.serverInfo = features.getServerInfo(); + this.serverCapabilities = features.getServerCapabilities(); + this.instructions = features.getInstructions(); + this.tools.addAll(features.getTools()); + this.resources.putAll(features.getResources()); + this.resourceTemplates.addAll(features.getResourceTemplates()); + this.prompts.putAll(features.getPrompts()); + + Map> requestHandlers = new HashMap<>(); + + // Initialize request handlers for standard MCP methods + + // Ping MUST respond with an empty data, but not NULL response. + requestHandlers.put(McpSchema.METHOD_PING, + (exchange, params) -> CompletableFuture.completedFuture(Collections.emptyMap())); + + // Add tools API handlers if the tool capability is enabled + if (this.serverCapabilities.getTools() != null) { + requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler()); + requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler()); + } + + // Add resources API handlers if provided + if (this.serverCapabilities.getResources() != null) { + requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler()); + requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler()); + requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler()); + } + + // Add prompts API handlers if provider exists + if (this.serverCapabilities.getPrompts() != null) { + requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler()); + requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler()); + } + + if (this.serverCapabilities.getLogging() != null) { + requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler()); + } + + Map notificationHandlers = new HashMap<>(); + + notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, + (exchange, params) -> CompletableFuture.completedFuture(null)); + + List, CompletableFuture>> rootsChangeConsumers = features + .getRootsChangeConsumers(); + + if (Utils.isEmpty(rootsChangeConsumers)) { + rootsChangeConsumers = Collections.singletonList( + (exchange, roots) -> CompletableFuture.runAsync(() -> + logger.warn("Roots list changed notification, but no consumers provided. Roots list changed: {}", roots)) + ); + } + + notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED, + rootsListChangedNotificationHandler(rootsChangeConsumers)); + + mcpTransportProvider.setSessionFactory(transport -> { + Channel channel = transport.getChannel(); + return new McpServerSession(UUID.randomUUID().toString(), + requestTimeout, transport, this::initializeRequestHandler, + () -> CompletableFuture.completedFuture(null), requestHandlers, notificationHandlers, + channel); + }); + } + + // --------------------------------------- + // Lifecycle Management + // --------------------------------------- + private CompletableFuture initializeRequestHandler( + McpSchema.InitializeRequest initializeRequest) { + return CompletableFuture.supplyAsync(() -> { + logger.info("Client initialize request - Protocol: {}, Capabilities: {}, Info: {}", + initializeRequest.getProtocolVersion(), initializeRequest.getCapabilities(), + initializeRequest.getClientInfo()); + + // The server MUST respond with the highest protocol version it supports + // if + // it does not support the requested (e.g. Client) version. + String serverProtocolVersion = protocolVersions.get(protocolVersions.size() - 1); + + if (protocolVersions.contains(initializeRequest.getProtocolVersion())) { + serverProtocolVersion = initializeRequest.getProtocolVersion(); + } + else { + logger.warn( + "Client requested unsupported protocol version: {}, " + "so the server will suggest {} instead", + initializeRequest.getProtocolVersion(), serverProtocolVersion); + } + + return new McpSchema.InitializeResult(serverProtocolVersion, serverCapabilities, serverInfo, instructions); + }); + } + + public McpSchema.ServerCapabilities getServerCapabilities() { + return this.serverCapabilities; + } + + public McpSchema.Implementation getServerInfo() { + return this.serverInfo; + } + + public CompletableFuture closeGracefully() { + return this.mcpTransportProvider.closeGracefully(); + } + + public void close() { + this.mcpTransportProvider.close(); + } + + private McpServerSession.NotificationHandler rootsListChangedNotificationHandler( + List, CompletableFuture>> rootsChangeConsumers) { + return (exchange, params) -> { + CompletableFuture futureRoots = exchange.listRoots(); + + return futureRoots.thenCompose(listRootsResult -> { + List roots = listRootsResult.getRoots(); + List> futures = new ArrayList<>(); + for (BiFunction, CompletableFuture> consumer : rootsChangeConsumers) { + CompletableFuture future = consumer.apply(exchange, roots).exceptionally(error -> { + logger.error("Error handling roots list change notification", error); + return null; + }); + futures.add(future); + } + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + }); + }; + } + + // --------------------------------------- + // Tool Management + // --------------------------------------- + + public CompletableFuture addTool(McpServerFeatures.ToolSpecification toolSpecification) { + if (toolSpecification == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Tool specification must not be null")); + return future; + } + if (toolSpecification.getTool() == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Tool must not be null")); + return future; + } + if (toolSpecification.getCall() == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Tool call handler must not be null")); + return future; + } + if (this.serverCapabilities.getTools() == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Server must be configured with tool capabilities")); + return future; + } + + return CompletableFuture.supplyAsync(() -> { + // Check for duplicate tool names + if (this.tools.stream().anyMatch(th -> th.getTool().getName().equals(toolSpecification.getTool().getName()))) { + throw new CompletionException( + new McpError("Tool with name '" + toolSpecification.getTool().getName() + "' already exists")); + } + this.tools.add(toolSpecification); + logger.debug("Added tool handler: {}", toolSpecification.getTool().getName()); + return null; + }).thenCompose(ignored -> { + if (this.serverCapabilities.getTools().getListChanged()) { + return notifyToolsListChanged(); + } + return CompletableFuture.completedFuture(null); + }).exceptionally(ex -> { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + logger.error("Error while adding tool", cause); + throw new CompletionException(cause); + }); + } + + public CompletableFuture removeTool(String toolName) { + if (toolName == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Tool name must not be null")); + return future; + } + if (this.serverCapabilities.getTools() == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Server must be configured with tool capabilities")); + return future; + } + + return CompletableFuture.supplyAsync(() -> { + boolean removed = this.tools.removeIf(spec -> spec.getTool().getName().equals(toolName)); + if (!removed) { + throw new CompletionException(new McpError("Tool with name '" + toolName + "' not found")); + } + logger.debug("Removed tool handler: {}", toolName); + return null; + }).thenCompose(ignored -> { + if (this.serverCapabilities.getTools().getListChanged()) { + return notifyToolsListChanged(); + } + return CompletableFuture.completedFuture(null); + }).exceptionally(ex -> { + Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex; + logger.error("Error while removing tool '{}'", toolName, cause); + throw new CompletionException(cause); + }); + } + + public CompletableFuture notifyToolsListChanged() { + logger.debug("Notifying clients about tool list changes"); + return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null); + } + + private McpServerSession.RequestHandler toolsListRequestHandler() { + return (exchange, params) -> { + List tools = new ArrayList<>(); + for (McpServerFeatures.ToolSpecification toolSpec : this.tools) { + tools.add(toolSpec.getTool()); + } + + return CompletableFuture.completedFuture(new McpSchema.ListToolsResult(tools, null)); + }; + } + + private McpServerSession.RequestHandler toolsCallRequestHandler() { + return (exchange, params) -> { + McpSchema.CallToolRequest callToolRequest = objectMapper.convertValue(params, + new TypeReference() { + }); + Optional toolSpecification = this.tools.stream() + .filter(tr -> callToolRequest.getName().equals(tr.getTool().getName())) + .findAny(); + + if (!toolSpecification.isPresent()) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("no tool found: " + callToolRequest.getName())); + return future; + } + + return toolSpecification.get().getCall().apply(exchange, callToolRequest.getArguments()); + }; + } + + // --------------------------------------- + // Resource Management + // --------------------------------------- + + public CompletableFuture addResource(McpServerFeatures.ResourceSpecification resourceSpecification) { + if (resourceSpecification == null || resourceSpecification.getResource() == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Resource must not be null")); + return future; + } + if (this.serverCapabilities.getResources() == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Server must be configured with resource capabilities")); + return future; + } + + return CompletableFuture.supplyAsync(() -> { + if (this.resources.putIfAbsent(resourceSpecification.getResource().getUri(), resourceSpecification) != null) { + throw new CompletionException(new McpError( + "Resource with URI '" + resourceSpecification.getResource().getUri() + "' already exists")); + } + logger.debug("Added resource handler: {}", resourceSpecification.getResource().getUri()); + return null; + }).thenCompose(ignored -> { + if (this.serverCapabilities.getResources().getListChanged()) { + return notifyResourcesListChanged(); + } + return CompletableFuture.completedFuture(null); + }).exceptionally(ex -> { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + logger.error("Error while adding resource '{}'", resourceSpecification.getResource().getUri(), cause); + throw new CompletionException(cause); + }); + } + + public CompletableFuture removeResource(String resourceUri) { + if (resourceUri == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Resource URI must not be null")); + return future; + } + if (this.serverCapabilities.getResources() == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Server must be configured with resource capabilities")); + return future; + } + + return CompletableFuture.supplyAsync(() -> { + McpServerFeatures.ResourceSpecification removed = this.resources.remove(resourceUri); + if (removed == null) { + throw new CompletionException(new McpError("Resource with URI '" + resourceUri + "' not found")); + } + + logger.debug("Removed resource handler: {}", resourceUri); + return null; + }).thenCompose(ignored -> { + if (this.serverCapabilities.getResources().getListChanged()) { + return notifyResourcesListChanged(); + } + return CompletableFuture.completedFuture(null); + }).exceptionally(ex -> { + Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex; + logger.error("Error while removing resource '{}'", resourceUri, cause); + throw new CompletionException(cause); + }); + } + + public CompletableFuture notifyResourcesListChanged() { + return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null); + } + + private McpServerSession.RequestHandler resourcesListRequestHandler() { + return (exchange, params) -> { + List resourceList = new ArrayList<>(); + for (McpServerFeatures.ResourceSpecification spec : this.resources.values()) { + resourceList.add(spec.getResource()); + } + return CompletableFuture.completedFuture(new McpSchema.ListResourcesResult(resourceList, null)); + }; + } + + private McpServerSession.RequestHandler resourceTemplateListRequestHandler() { + return (exchange, params) -> CompletableFuture + .completedFuture(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null)); + } + + private McpServerSession.RequestHandler resourcesReadRequestHandler() { + return (exchange, params) -> { + McpSchema.ReadResourceRequest resourceRequest = objectMapper.convertValue(params, + new TypeReference() { + }); + String resourceUri = resourceRequest.getUri(); + McpServerFeatures.ResourceSpecification specification = this.resources.get(resourceUri); + if (specification != null) { + return specification.getReadHandler().apply(exchange, resourceRequest); + } + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Resource not found: " + resourceUri)); + return future; + }; + } + + // --------------------------------------- + // Prompt Management + // --------------------------------------- + + public CompletableFuture addPrompt(McpServerFeatures.PromptSpecification promptSpecification) { + if (promptSpecification == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Prompt specification must not be null")); + return future; + } + if (this.serverCapabilities.getPrompts() == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Server must be configured with prompt capabilities")); + return future; + } + + return CompletableFuture.supplyAsync(() -> { + McpServerFeatures.PromptSpecification existing = this.prompts + .putIfAbsent(promptSpecification.getPrompt().getName(), promptSpecification); + if (existing != null) { + throw new CompletionException( + new McpError("Prompt with name '" + promptSpecification.getPrompt().getName() + "' already exists")); + } + + logger.debug("Added prompt handler: {}", promptSpecification.getPrompt().getName()); + return null; + }).thenCompose(ignored -> { + if (this.serverCapabilities.getPrompts().getListChanged()) { + return notifyPromptsListChanged(); + } + return CompletableFuture.completedFuture(null); + }).exceptionally(ex -> { + Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex; + logger.error("Error while adding prompt '{}'", promptSpecification.getPrompt().getName(), cause); + throw new CompletionException(cause); + }); + } + + public CompletableFuture removePrompt(String promptName) { + if (promptName == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Prompt name must not be null")); + return future; + } + if (this.serverCapabilities.getPrompts() == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Server must be configured with prompt capabilities")); + return future; + } + + return CompletableFuture.supplyAsync(() -> { + McpServerFeatures.PromptSpecification removed = this.prompts.remove(promptName); + if (removed == null) { + throw new CompletionException(new McpError("Prompt with name '" + promptName + "' not found")); + } + logger.debug("Removed prompt handler: {}", promptName); + return null; + }).thenCompose(ignored -> { + if (this.serverCapabilities.getPrompts().getListChanged()) { + return notifyPromptsListChanged(); + } + return CompletableFuture.completedFuture(null); + }).exceptionally(ex -> { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + logger.error("Error while removing prompt '{}'", promptName, cause); + throw new CompletionException(cause); + }); + } + + public CompletableFuture notifyPromptsListChanged() { + return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null); + } + + private McpServerSession.RequestHandler promptsListRequestHandler() { + return (exchange, params) -> { + List promptList = new ArrayList<>(); + for (McpServerFeatures.PromptSpecification promptSpec : this.prompts.values()) { + promptList.add(promptSpec.getPrompt()); + } + return CompletableFuture.completedFuture(new McpSchema.ListPromptsResult(promptList, null)); + }; + } + + private McpServerSession.RequestHandler promptsGetRequestHandler() { + return (exchange, params) -> { + McpSchema.GetPromptRequest promptRequest = objectMapper.convertValue(params, + new TypeReference() { + }); + + McpServerFeatures.PromptSpecification specification = this.prompts.get(promptRequest.getName()); + if (specification != null) { + return specification.getPromptHandler().apply(exchange, promptRequest); + } + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Prompt not found: " + promptRequest.getName())); + return future; + }; + } + + // --------------------------------------- + // Logging Management + // --------------------------------------- + public CompletableFuture loggingNotification( + McpSchema.LoggingMessageNotification loggingMessageNotification) { + Assert.notNull(loggingMessageNotification, "Logging message must not be null"); + + if (loggingMessageNotification.getLevel().level() < minLoggingLevel.level()) { + return CompletableFuture.completedFuture(null); + } + + return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_MESSAGE, + loggingMessageNotification); + } + + private McpServerSession.RequestHandler> setLoggerRequestHandler() { + return (exchange, params) -> { + try { + McpSchema.SetLevelRequest request = this.objectMapper.convertValue(params, + McpSchema.SetLevelRequest.class); + this.minLoggingLevel = request.getLevel(); + return CompletableFuture.completedFuture(Collections.emptyMap()); + } + catch (Exception e) { + CompletableFuture> future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("An error occurred while processing a request to set the log level: " + e.getMessage())); + return future; + } + }; + } + + // --------------------------------------- + // Sampling + // --------------------------------------- + + public void setProtocolVersions(List protocolVersions) { + this.protocolVersions = protocolVersions; + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpNettyServerExchange.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpNettyServerExchange.java new file mode 100644 index 000000000..45a31035b --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpNettyServerExchange.java @@ -0,0 +1,214 @@ +package com.taobao.arthas.mcp.server.protocol.server; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.taobao.arthas.mcp.server.protocol.spec.McpError; +import com.taobao.arthas.mcp.server.protocol.spec.McpSchema; +import com.taobao.arthas.mcp.server.protocol.spec.McpSchema.LoggingLevel; +import com.taobao.arthas.mcp.server.protocol.spec.McpSchema.LoggingMessageNotification; +import com.taobao.arthas.mcp.server.protocol.spec.McpServerSession; +import com.taobao.arthas.mcp.server.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; + + + +/** + * Represents the interaction between MCP server and client. Provides methods for communication, logging, and context management. + * + * @author Yeaury + *

+ * McpNettyServerExchange provides various methods for communicating with the client, including: + *

    + *
  • Sending requests and notifications + *
  • Getting client capabilities and information + *
  • Handling logging notifications + *
  • Creating client messages + *
  • Managing root directories + *
+ *

+ * Each exchange object is associated with a specific client session, providing context and capabilities for that session. + */ +public class McpNettyServerExchange { + + private static final Logger logger = LoggerFactory.getLogger(McpNettyServerExchange.class); + + private final McpServerSession session; + + private final McpSchema.ClientCapabilities clientCapabilities; + + private final McpSchema.Implementation clientInfo; + + private volatile LoggingLevel minLoggingLevel = LoggingLevel.INFO; + + private static final TypeReference CREATE_MESSAGE_RESULT_TYPE_REF = + new TypeReference() { + }; + + private static final TypeReference LIST_ROOTS_RESULT_TYPE_REF = + new TypeReference() { + }; + + /** + * Create a new server exchange object. + * @param session Session associated with the client + * @param clientCapabilities Client capabilities + * @param clientInfo Client information + */ + public McpNettyServerExchange(McpServerSession session, McpSchema.ClientCapabilities clientCapabilities, + McpSchema.Implementation clientInfo) { + Assert.notNull(session, "Session cannot be null"); + this.session = session; + this.clientCapabilities = clientCapabilities; + this.clientInfo = clientInfo; + logger.debug("Created new server exchange, session ID: {}, client: {}", session.getId(), clientInfo); + } + + /** + * Create a new server exchange object. This constructor is used when the session exists but client capabilities and information are unknown. + * For example, for exchange objects created in request handlers. + * @param session Server session + * @param objectMapper JSON object mapper + */ + public McpNettyServerExchange(McpServerSession session, ObjectMapper objectMapper) { + Assert.notNull(session, "Session cannot be null"); + this.session = session; + this.clientCapabilities = null; + this.clientInfo = null; + logger.debug("Created new server exchange, session ID: {}, client info unknown", session.getId()); + } + + /** + * Get client capabilities. + * @return Client capabilities + */ + public McpSchema.ClientCapabilities getClientCapabilities() { + return this.clientCapabilities; + } + + /** + * Get client information. + * @return Client information + */ + public McpSchema.Implementation getClientInfo() { + return this.clientInfo; + } + + /** + * Send a notification with parameters to the client. + * @param method Notification method name to send to the client + * @param params Parameters to send with the notification + * @return A CompletableFuture that completes when the notification is sent + */ + public CompletableFuture sendNotification(String method, Object params) { + Assert.hasText(method, "Method name cannot be empty"); + + logger.debug("Sending notification to client - method: {}, session: {}", method, this.session.getId()); + return this.session.sendNotification(method, params).whenComplete((result, error) -> { + if (error != null) { + logger.error("Notification failed - method: {}, session: {}, error: {}", method, this.session.getId(), error.getMessage()); + } + else { + logger.debug("Notification sent successfully - method: {}, session: {}", method, this.session.getId()); + } + }); + } + + /** + * Create a new message using client sampling capability. MCP provides a standardized way for servers to request + * LLM sampling ("completion" or "generation") through the client. This flow allows clients to maintain control + * over model access, selection, and permissions while enabling servers to leverage AI capabilities—without server + * API keys. Servers can request text or image-based interactions and can optionally include context from the MCP + * server in their prompts. + * @param createMessageRequest Request to create a new message + * @return A CompletableFuture that completes when the message is created + */ + public CompletableFuture createMessage( + McpSchema.CreateMessageRequest createMessageRequest) { + if (this.clientCapabilities == null) { + logger.error("Client not initialized, cannot create message"); + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Client must be initialized first. Please call initialize method!")); + return future; + } + if (this.clientCapabilities.getSampling() == null) { + logger.error("Client not configured with sampling capability, cannot create message"); + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("Client must be configured with sampling capability")); + return future; + } + + logger.debug("Creating client message, session ID: {}", this.session.getId()); + return this.session + .sendRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, createMessageRequest, CREATE_MESSAGE_RESULT_TYPE_REF) + .whenComplete((result, error) -> { + if (error != null) { + logger.error("Failed to create message, session ID: {}, error: {}", this.session.getId(), error.getMessage()); + } + else { + logger.debug("Message created successfully, session ID: {}", this.session.getId()); + } + }); + } + + /** + * Get a list of all root directories provided by the client. + * @return Sends out the CompletableFuture of the root list result + */ + public CompletableFuture listRoots() { + return this.listRoots(null); + } + + /** + * Get the client-provided list of pagination roots. + * Optional pagination cursor @param cursor for the previous list request + * @return Emits a CompletableFuture containing the results of the root list + */ + public CompletableFuture listRoots(String cursor) { + logger.debug("Requesting root list, session ID: {}, cursor: {}", this.session.getId(), cursor); + return this.session + .sendRequest(McpSchema.METHOD_ROOTS_LIST, new McpSchema.PaginatedRequest(cursor), + LIST_ROOTS_RESULT_TYPE_REF) + .whenComplete((result, error) -> { + if (error != null) { + logger.error("Failed to get root list, session ID: {}, error: {}", this.session.getId(), error.getMessage()); + } + else { + logger.debug("Root list retrieved successfully, session ID: {}", this.session.getId()); + } + }); + } + + public CompletableFuture loggingNotification(LoggingMessageNotification loggingMessageNotification) { + if (loggingMessageNotification == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new McpError("日志消息不能为空")); + return future; + } + + if (this.isNotificationForLevelAllowed(loggingMessageNotification.getLevel())) { + return this.session.sendNotification(McpSchema.METHOD_NOTIFICATION_MESSAGE, loggingMessageNotification) + .whenComplete((result, error) -> { + if (error != null) { + logger.error("Failed to send logging notification, level: {}, session ID: {}, error: {}", loggingMessageNotification.getLevel(), + this.session.getId(), error.getMessage()); + } + }); + } + return CompletableFuture.completedFuture(null); + } + + public void setMinLoggingLevel(LoggingLevel minLoggingLevel) { + Assert.notNull(minLoggingLevel, "最低日志级别不能为空"); + logger.debug("Setting minimum logging level: {}, session ID: {}", minLoggingLevel, this.session.getId()); + this.minLoggingLevel = minLoggingLevel; + } + + private boolean isNotificationForLevelAllowed(LoggingLevel loggingLevel) { + return loggingLevel.level() >= this.minLoggingLevel.level(); + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpServer.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpServer.java new file mode 100644 index 000000000..10dc77374 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpServer.java @@ -0,0 +1,242 @@ +package com.taobao.arthas.mcp.server.protocol.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.taobao.arthas.mcp.server.protocol.spec.*; +import com.taobao.arthas.mcp.server.util.Assert; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; + +/** + * MCP server interface and builder for Netty-based implementation. + * + * @author Yeaury + */ +public interface McpServer { + + static NettySpecification netty(McpServerTransportProvider transportProvider) { + return new NettySpecification(transportProvider); + } + + /** + * serverSpecification + */ + class NettySpecification { + + private static final McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server", + "1.0.0"); + + private final McpServerTransportProvider transportProvider; + + private ObjectMapper objectMapper; + + private McpSchema.Implementation serverInfo = DEFAULT_SERVER_INFO; + + private McpSchema.ServerCapabilities serverCapabilities; + + private String instructions; + + /** + * The Model Context Protocol (MCP) allows servers to expose tools that can be + * invoked by language models. Tools enable models to interact with external + * systems, such as querying databases, calling APIs, or performing computations. + * Each tool is uniquely identified by a name and includes metadata describing its + * schema. + */ + private final List tools = new ArrayList<>(); + + /** + * The Model Context Protocol (MCP) provides a standardized way for servers to + * expose resources to clients. Resources allow servers to share data that + * provides context to language models, such as files, database schemas, or + * application-specific information. Each resource is uniquely identified by a + * URI. + */ + private final Map resources = new HashMap<>(); + + private final List resourceTemplates = new ArrayList<>(); + + /** + * The Model Context Protocol (MCP) provides a standardized way for servers to + * expose prompt templates to clients. Prompts allow servers to provide structured + * messages and instructions for interacting with language models. Clients can + * discover available prompts, retrieve their contents, and provide arguments to + * customize them. + */ + private final Map prompts = new HashMap<>(); + + private final List, CompletableFuture>> rootsChangeHandlers = new ArrayList<>(); + + private Duration requestTimeout = Duration.ofSeconds(10); // Default timeout + + private NettySpecification(McpServerTransportProvider transportProvider) { + Assert.notNull(transportProvider, "Transport provider must not be null"); + this.transportProvider = transportProvider; + } + + /** + * Sets the server implementation information that will be shared with clients + * during connection initialization. This helps with version compatibility, + * debugging, and server identification. + * @param serverInfo The server implementation details including name and version. + * Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if serverInfo is null + */ + public NettySpecification serverInfo(McpSchema.Implementation serverInfo) { + Assert.notNull(serverInfo, "Server info must not be null"); + this.serverInfo = serverInfo; + return this; + } + + public NettySpecification requestTimeout(Duration requestTimeout) { + Assert.notNull(requestTimeout, "Request timeout must not be null"); + this.requestTimeout = requestTimeout; + return this; + } + + public NettySpecification serverInfo(String name, String version) { + Assert.hasText(name, "Name must not be null or empty"); + Assert.hasText(version, "Version must not be null or empty"); + this.serverInfo = new McpSchema.Implementation(name, version); + return this; + } + + public NettySpecification instructions(String instructions) { + this.instructions = instructions; + return this; + } + + public NettySpecification capabilities(McpSchema.ServerCapabilities serverCapabilities) { + this.serverCapabilities = serverCapabilities; + return this; + } + + public NettySpecification tool(McpSchema.Tool tool, + BiFunction, CompletableFuture> handler) { + Assert.notNull(tool, "Tool must not be null"); + Assert.notNull(handler, "Handler must not be null"); + + this.tools.add(new McpServerFeatures.ToolSpecification(tool, handler)); + + return this; + } + + public NettySpecification tools(List toolRegistrations) { + Assert.notNull(toolRegistrations, "Tool handlers list must not be null"); + this.tools.addAll(toolRegistrations); + return this; + } + + public NettySpecification tools(McpServerFeatures.ToolSpecification... toolRegistrations) { + for (McpServerFeatures.ToolSpecification tool : toolRegistrations) { + this.tools.add(tool); + } + return this; + } + + public NettySpecification resources(Map resourceSpecifications) { + Assert.notNull(resourceSpecifications, "Resource handlers map must not be null"); + this.resources.putAll(resourceSpecifications); + return this; + } + + public NettySpecification resources(List resourceSpecifications) { + Assert.notNull(resourceSpecifications, "Resource handlers list must not be null"); + for (McpServerFeatures.ResourceSpecification resource : resourceSpecifications) { + this.resources.put(resource.getResource().getUri(), resource); + } + return this; + } + + public NettySpecification resources(McpServerFeatures.ResourceSpecification... resourceSpecifications) { + Assert.notNull(resourceSpecifications, "Resource handlers list must not be null"); + for (McpServerFeatures.ResourceSpecification resource : resourceSpecifications) { + this.resources.put(resource.getResource().getUri(), resource); + } + return this; + } + + public NettySpecification resourceTemplates(List resourceTemplates) { + Assert.notNull(resourceTemplates, "Resource templates must not be null"); + this.resourceTemplates.addAll(resourceTemplates); + return this; + } + + public NettySpecification resourceTemplates(McpSchema.ResourceTemplate... resourceTemplates) { + Assert.notNull(resourceTemplates, "Resource templates must not be null"); + this.resourceTemplates.addAll(Arrays.asList(resourceTemplates)); + return this; + } + + public NettySpecification prompts(Map prompts) { + Assert.notNull(prompts, "Prompts map must not be null"); + this.prompts.putAll(prompts); + return this; + } + + public NettySpecification prompts(List prompts) { + Assert.notNull(prompts, "Prompts map must not be null"); + for (McpServerFeatures.PromptSpecification prompt : prompts) { + this.prompts.put(prompt.getPrompt().getName(), prompt); + } + return this; + } + + public NettySpecification prompts(McpServerFeatures.PromptSpecification... prompts) { + Assert.notNull(prompts, "Prompts map must not be null"); + for (McpServerFeatures.PromptSpecification prompt : prompts) { + this.prompts.put(prompt.getPrompt().getName(), prompt); + } + return this; + } + + public NettySpecification rootsChangeHandler( + BiFunction, CompletableFuture> handler) { + Assert.notNull(handler, "Consumer must not be null"); + this.rootsChangeHandlers.add(handler); + return this; + } + + + public NettySpecification rootsChangeHandlers( + List, CompletableFuture>> handlers) { + Assert.notNull(handlers, "Handlers list must not be null"); + this.rootsChangeHandlers.addAll(handlers); + return this; + } + + public NettySpecification rootsChangeHandlers( + @SuppressWarnings("unchecked") BiFunction, CompletableFuture>... handlers) { + Assert.notNull(handlers, "Handlers list must not be null"); + return this.rootsChangeHandlers(Arrays.asList(handlers)); + } + + public NettySpecification objectMapper(ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + this.objectMapper = objectMapper; + return this; + } + + public McpNettyServer build() { + ObjectMapper mapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper(); + return new McpNettyServer( + this.transportProvider, + mapper, + this.requestTimeout, + new McpServerFeatures.McpServerConfig( + this.serverInfo, + this.serverCapabilities, + this.tools, + this.resources, + this.resourceTemplates, + this.prompts, + this.rootsChangeHandlers, + this.instructions + ) + ); + } + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpServerFeatures.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpServerFeatures.java new file mode 100644 index 000000000..efc75014d --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/McpServerFeatures.java @@ -0,0 +1,219 @@ +package com.taobao.arthas.mcp.server.protocol.server; + +import com.taobao.arthas.mcp.server.protocol.spec.*; +import com.taobao.arthas.mcp.server.util.Assert; +import com.taobao.arthas.mcp.server.util.Utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; + +/** + * MCP server function specification, the server can choose the supported features. + * This implementation only provides an asynchronous API. + * + * @author Yeaury + */ +public class McpServerFeatures { + + public static class McpServerConfig { + private final McpSchema.Implementation serverInfo; + private final McpSchema.ServerCapabilities serverCapabilities; + private final List tools; + private final Map resources; + private final List resourceTemplates; + private final Map prompts; + private final List, CompletableFuture>> rootsChangeConsumers; + private final String instructions; + + public McpServerConfig( + McpSchema.Implementation serverInfo, + McpSchema.ServerCapabilities serverCapabilities, + List tools, + Map resources, + List resourceTemplates, + Map prompts, + List, CompletableFuture>> rootsChangeConsumers, + String instructions) { + + Assert.notNull(serverInfo, "The server information cannot be empty"); + + // If serverCapabilities is empty, the appropriate capability configuration + // is automatically built based on the provided capabilities + if (serverCapabilities == null) { + serverCapabilities = new McpSchema.ServerCapabilities( + null, // experimental + new McpSchema.ServerCapabilities.LoggingCapabilities(), + !Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null, + !Utils.isEmpty(resources) ? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null, + !Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null); + } + + this.tools = (tools != null) ? tools : Collections.emptyList(); + this.resources = (resources != null) ? resources : Collections.emptyMap(); + this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : Collections.emptyList(); + this.prompts = (prompts != null) ? prompts : Collections.emptyMap(); + this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : Collections.emptyList(); + this.serverInfo = serverInfo; + this.serverCapabilities = serverCapabilities; + this.instructions = instructions; + } + + public McpSchema.Implementation getServerInfo() { + return serverInfo; + } + + public McpSchema.ServerCapabilities getServerCapabilities() { + return serverCapabilities; + } + + public List getTools() { + return tools; + } + + public Map getResources() { + return resources; + } + + public List getResourceTemplates() { + return resourceTemplates; + } + + public Map getPrompts() { + return prompts; + } + + public List, CompletableFuture>> getRootsChangeConsumers() { + return rootsChangeConsumers; + } + + public String getInstructions() { + return instructions; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private McpSchema.Implementation serverInfo; + private McpSchema.ServerCapabilities serverCapabilities; + private final List tools = new ArrayList<>(); + private final Map resources = new HashMap<>(); + private final List resourceTemplates = new ArrayList<>(); + private final Map prompts = new HashMap<>(); + private final List, CompletableFuture>> rootsChangeConsumers = new ArrayList<>(); + private String instructions; + + public Builder serverInfo(McpSchema.Implementation serverInfo) { + this.serverInfo = serverInfo; + return this; + } + + public Builder serverCapabilities(McpSchema.ServerCapabilities serverCapabilities) { + this.serverCapabilities = serverCapabilities; + return this; + } + + public Builder addTool(ToolSpecification tool) { + this.tools.add(tool); + return this; + } + + public Builder addResource(String key, ResourceSpecification resource) { + this.resources.put(key, resource); + return this; + } + + public Builder addResourceTemplate(McpSchema.ResourceTemplate template) { + this.resourceTemplates.add(template); + return this; + } + + public Builder addPrompt(String key, PromptSpecification prompt) { + this.prompts.put(key, prompt); + return this; + } + + public Builder addRootsChangeConsumer( + BiFunction, CompletableFuture> consumer) { + this.rootsChangeConsumers.add(consumer); + return this; + } + + public Builder instructions(String instructions) { + this.instructions = instructions; + return this; + } + + public McpServerConfig build() { + return new McpServerConfig(serverInfo, serverCapabilities, tools, resources, resourceTemplates, prompts, + rootsChangeConsumers, instructions); + } + } + } + + public static class ToolSpecification { + private final McpSchema.Tool tool; + private final BiFunction, CompletableFuture> call; + + public ToolSpecification( + McpSchema.Tool tool, + BiFunction, CompletableFuture> call) { + this.tool = tool; + this.call = call; + } + + public McpSchema.Tool getTool() { + return tool; + } + + public BiFunction, CompletableFuture> getCall() { + return call; + } + } + + public static class ResourceSpecification { + private final McpSchema.Resource resource; + private final BiFunction> readHandler; + + public ResourceSpecification( + McpSchema.Resource resource, + BiFunction> readHandler) { + this.resource = resource; + this.readHandler = readHandler; + } + + public McpSchema.Resource getResource() { + return resource; + } + + public BiFunction> getReadHandler() { + return readHandler; + } + } + + public static class PromptSpecification { + private final McpSchema.Prompt prompt; + private final BiFunction> promptHandler; + + public PromptSpecification( + McpSchema.Prompt prompt, + BiFunction> promptHandler) { + this.prompt = prompt; + this.promptHandler = promptHandler; + } + + public McpSchema.Prompt getPrompt() { + return prompt; + } + + public BiFunction> getPromptHandler() { + return promptHandler; + } + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/handler/McpRequestHandler.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/handler/McpRequestHandler.java new file mode 100644 index 000000000..9c2d3eebf --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/handler/McpRequestHandler.java @@ -0,0 +1,379 @@ +package com.taobao.arthas.mcp.server.protocol.server.handler; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.taobao.arthas.mcp.server.protocol.spec.McpSchema; +import com.taobao.arthas.mcp.server.protocol.spec.McpServerSession; +import com.taobao.arthas.mcp.server.protocol.spec.McpServerTransport; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.*; +import io.netty.handler.codec.http.*; +import io.netty.util.CharsetUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * MCP (Model Context Protocol) Request Handler + * Used to handle model context protocol requests for Arthas. + * + * @author Yeaury + */ +public class McpRequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(McpRequestHandler.class); + + /** Message event type */ + public static final String MESSAGE_EVENT_TYPE = "message"; + + /** Endpoint event type */ + public static final String ENDPOINT_EVENT_TYPE = "endpoint"; + + /** Active session mapping: session ID -> session object */ + private final Map sessions = new ConcurrentHashMap<>(); + + /** SSE connection mapping: session ID -> Channel */ + private final Map sseChannels = new ConcurrentHashMap<>(); + + /** Message endpoint path */ + private final String messageEndpoint; + + /** SSE endpoint path */ + private final String sseEndpoint; + + /** Object mapper for JSON serialization/deserialization */ + private final ObjectMapper objectMapper; + + /** Session factory */ + private McpServerSession.Factory sessionFactory; + + /** + * Create a new McpRequestHandler instance. + * @param messageEndpoint Message endpoint path, e.g. "/mcp/message" + * @param sseEndpoint SSE endpoint path, e.g. "/mcp" + * @param objectMapper Object mapper for JSON serialization/deserialization + */ + public McpRequestHandler(String messageEndpoint, + String sseEndpoint, ObjectMapper objectMapper) { + this.messageEndpoint = messageEndpoint; + this.sseEndpoint = sseEndpoint; + this.objectMapper = objectMapper; + } + + public void handle(ChannelHandlerContext ctx, FullHttpRequest request){ + String uri = request.uri(); + + if (request.method() == HttpMethod.GET && uri.endsWith(sseEndpoint)) { + handleSseRequest(ctx); + return; + } + + if (request.method() == HttpMethod.POST && uri.contains(messageEndpoint)) { + handleMessageRequest(ctx, request); + return; + } + + if (request.method() == HttpMethod.OPTIONS) { + sendOptionsResponse(ctx); + return; + } + + sendNotFoundResponse(ctx); + } + + private void handleSseRequest(ChannelHandlerContext ctx) { + String sessionId = UUID.randomUUID().toString(); + HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/event-stream"); + response.headers().set(HttpHeaderNames.CACHE_CONTROL, "no-cache"); + response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE); + response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS, "*"); + response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, OPTIONS"); + + HttpUtil.setTransferEncodingChunked(response, true); + ctx.writeAndFlush(response); + + sseChannels.put(sessionId, ctx.channel()); + + HttpServerTransport transport = new HttpServerTransport(sessionId, ctx.channel()); + + McpServerSession session = sessionFactory.create(transport); + sessions.put(sessionId, session); + + String endpointInfo = messageEndpoint + "?sessionId=" + sessionId; + sendSseEvent(ctx, ENDPOINT_EVENT_TYPE, endpointInfo); + + logger.debug("SSE connection established, session ID: {}", sessionId); + } + + private void handleMessageRequest(ChannelHandlerContext ctx, FullHttpRequest request) { + String uri = request.uri(); + String sessionIdParam = "sessionId="; + int sessionIdStart = uri.indexOf(sessionIdParam); + + if (sessionIdStart == -1) { + sendErrorResponse(ctx, HttpResponseStatus.BAD_REQUEST, "Missing sessionId parameter"); + return; + } + String sessionId = uri.substring(sessionIdStart + sessionIdParam.length()); + + McpServerSession session = sessions.get(sessionId); + if (session == null) { + sendErrorResponse(ctx, HttpResponseStatus.NOT_FOUND, "Session not found"); + return; + } + + ByteBuf content = request.content(); + String jsonContent = content.toString(CharsetUtil.UTF_8); + try { + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, jsonContent); + + session.handle(message).whenComplete((result, ex) -> { + if (ex != null) { + logger.error("Error processing message", ex); + sendErrorResponse(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + } + else { + sendSuccessResponse(ctx); + } + }); + } + catch (Exception e) { + logger.error("Error parsing message", e); + sendErrorResponse(ctx, HttpResponseStatus.BAD_REQUEST, "Invalid message format"); + } + } + + private void sendSseEvent(ChannelHandlerContext ctx, String eventType, String data) { + StringBuilder eventBuilder = new StringBuilder(); + eventBuilder.append("event: ").append(eventType).append("\n"); + eventBuilder.append("data: ").append(data).append("\n\n"); + + ByteBuf buffer = Unpooled.copiedBuffer(eventBuilder.toString(), CharsetUtil.UTF_8); + ctx.writeAndFlush(new DefaultHttpContent(buffer)); + } + + private void sendOptionsResponse(ChannelHandlerContext ctx) { + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, OPTIONS"); + response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS, "*"); + response.headers().set(HttpHeaderNames.ACCESS_CONTROL_MAX_AGE, "86400"); + response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0); + + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + private void sendSuccessResponse(ChannelHandlerContext ctx) { + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json"); + response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0); + response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + private void sendErrorResponse(ChannelHandlerContext ctx, HttpResponseStatus status, String message) { + ByteBuf content = Unpooled.copiedBuffer("{\"error\":\"" + message + "\"}", CharsetUtil.UTF_8); + + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, content); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json"); + response.headers().set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes()); + response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + private void sendNotFoundResponse(ChannelHandlerContext ctx) { + ByteBuf content = Unpooled.copiedBuffer("{\"error\":\"Resource not found\"}", CharsetUtil.UTF_8); + + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_FOUND, + content); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json"); + response.headers().set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes()); + response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + public CompletableFuture notifyClients(String method, Object params) { + CompletableFuture future = new CompletableFuture<>(); + + if (sessions.isEmpty()) { + logger.debug("No active sessions to broadcast message"); + future.complete(null); + return future; + } + + logger.debug("Attempting to broadcast message to {} active sessions", sessions.size()); + + CompletableFuture[] futures = sessions.values() + .stream() + .map(session -> session.sendNotification(method, params).exceptionally(e -> { + logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()); + return null; + })) + .toArray(CompletableFuture[]::new); + + CompletableFuture.allOf(futures).whenComplete((result, ex) -> { + if (ex != null) { + logger.error("Error broadcasting message", ex); + future.completeExceptionally(ex); + } + else { + future.complete(null); + } + }); + + return future; + } + + public CompletableFuture closeGracefully() { + CompletableFuture future = new CompletableFuture<>(); + + try { + this.shutdown(); + future.complete(null); + } + catch (Exception e) { + future.completeExceptionally(e); + } + + return future; + } + + private void shutdown() { + for (McpServerSession session : this.sessions.values()) { + try { + session.close(); + } + catch (Exception e) { + logger.warn("Error closing session: {}", e.getMessage()); + } + } + logger.info("MCP HTTP server closed"); + } + + public void setSessionFactory(McpServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + public String getMessageEndpoint() { + return messageEndpoint; + } + + public String getSseEndpoint() { + return sseEndpoint; + } + + private class HttpServerTransport implements McpServerTransport { + + private final String sessionId; + + private final Channel channel; + + public HttpServerTransport(String sessionId, Channel channel) { + this.sessionId = sessionId; + this.channel = channel; + } + + public String getSessionId() { + return sessionId; + } + + @Override + public Channel getChannel() { + return channel; + } + + @Override + public CompletableFuture sendMessage(McpSchema.JSONRPCMessage message) { + CompletableFuture future = new CompletableFuture<>(); + + if (!channel.isActive()) { + future.completeExceptionally(new RuntimeException("Channel is not active")); + return future; + } + + try { + String jsonMessage = objectMapper.writeValueAsString(message); + logger.debug("sendMessage: {}", jsonMessage); + + StringBuilder eventBuilder = new StringBuilder(); + eventBuilder.append("event: ").append(MESSAGE_EVENT_TYPE).append("\n"); + eventBuilder.append("data: ").append(jsonMessage).append("\n\n"); + + ByteBuf buffer = Unpooled.copiedBuffer(eventBuilder.toString(), CharsetUtil.UTF_8); + + channel.writeAndFlush(new DefaultHttpContent(buffer)) + .addListener((ChannelFutureListener) channelFuture -> { + if (channelFuture.isSuccess()) { + future.complete(null); + } + else { + future.completeExceptionally(channelFuture.cause()); + } + }); + } + catch (Exception e) { + logger.error("Failed to send message to session {}: {}", sessionId, e.getMessage()); + sessions.remove(sessionId); + future.completeExceptionally(e); + } + + return future; + } + + @Override + public T unmarshalFrom(Object data, TypeReference typeRef) { + return objectMapper.convertValue(data, typeRef); + } + + @Override + public CompletableFuture closeGracefully() { + CompletableFuture future = new CompletableFuture<>(); + + try { + sessions.remove(sessionId); + sseChannels.remove(sessionId); + + if (channel.isActive()) { + channel.close().addListener((ChannelFutureListener) channelFuture -> { + if (channelFuture.isSuccess()) { + future.complete(null); + } + else { + future.completeExceptionally(channelFuture.cause()); + } + }); + } + else { + future.complete(null); + } + } + catch (Exception e) { + future.completeExceptionally(e); + } + + return future; + } + + @Override + public void close() { + sessions.remove(sessionId); + sseChannels.remove(sessionId); + + if (channel.isActive()) { + channel.close(); + } + } + + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/transport/HttpNettyServerTransportProvider.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/transport/HttpNettyServerTransportProvider.java new file mode 100644 index 000000000..a21259dca --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/server/transport/HttpNettyServerTransportProvider.java @@ -0,0 +1,90 @@ +package com.taobao.arthas.mcp.server.protocol.server.transport; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.taobao.arthas.mcp.server.protocol.server.handler.McpRequestHandler; +import com.taobao.arthas.mcp.server.protocol.spec.McpServerSession; +import com.taobao.arthas.mcp.server.protocol.spec.McpServerTransportProvider; +import com.taobao.arthas.mcp.server.util.Assert; + +import java.util.concurrent.CompletableFuture; + +/** + * HTTP MCP server transport provider implementation based on Netty, + * supporting server-to-client communication via SSE. + * + * @author Yeaury + */ +public class HttpNettyServerTransportProvider implements McpServerTransportProvider { + public static final String DEFAULT_SSE_ENDPOINT = "/mcp"; + public static final String DEFAULT_MESSAGE_ENDPOINT = "/mcp/message"; + private final McpRequestHandler mcpRequestHandler; + + /** + * Create a new HttpServerTransportProvider instance. + * @param messageEndpoint Message endpoint path, e.g. "/mcp/message" + * @param sseEndpoint SSE endpoint path, e.g. "/mcp" + * @param objectMapper Object mapper for JSON serialization/deserialization + */ + public HttpNettyServerTransportProvider(String messageEndpoint, String sseEndpoint, + ObjectMapper objectMapper) { + Assert.hasText(messageEndpoint, "Message endpoint path cannot be empty"); + Assert.hasText(sseEndpoint, "SSE endpoint path cannot be empty"); + Assert.notNull(objectMapper, "Object mapper cannot be null"); + + this.mcpRequestHandler = new McpRequestHandler(messageEndpoint, sseEndpoint, objectMapper); + } + + @Override + public void setSessionFactory(McpServerSession.Factory sessionFactory) { + this.mcpRequestHandler.setSessionFactory(sessionFactory); + } + + @Override + public CompletableFuture notifyClients(String method, Object params) { + return mcpRequestHandler.notifyClients(method, params); + } + + @Override + public CompletableFuture closeGracefully() { + return mcpRequestHandler.closeGracefully(); + } + + @Override + public McpRequestHandler getMcpRequestHandler() { + return mcpRequestHandler; + } + + public static class Builder { + + private String messageEndpoint = DEFAULT_MESSAGE_ENDPOINT; + + private String sseEndpoint = DEFAULT_SSE_ENDPOINT; + + private ObjectMapper objectMapper = new ObjectMapper(); + + public Builder messageEndpoint(String messageEndpoint) { + this.messageEndpoint = messageEndpoint; + return this; + } + + public Builder sseEndpoint(String sseEndpoint) { + this.sseEndpoint = sseEndpoint; + return this; + } + + public Builder objectMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + return this; + } + + public HttpNettyServerTransportProvider build() { + return new HttpNettyServerTransportProvider(messageEndpoint, sseEndpoint, objectMapper); + } + + } + + public static Builder builder() { + return new Builder(); + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpError.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpError.java new file mode 100644 index 000000000..2c93d646e --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpError.java @@ -0,0 +1,27 @@ +package com.taobao.arthas.mcp.server.protocol.spec; + +import com.taobao.arthas.mcp.server.protocol.spec.McpSchema.JSONRPCResponse.JSONRPCError; + +/** + * Exception class for representing JSON-RPC errors in MCP protocol. + * + * @author Yeaury + */ +public class McpError extends RuntimeException { + + private JSONRPCError jsonRpcError; + + public McpError(JSONRPCError jsonRpcError) { + super(jsonRpcError.getMessage()); + this.jsonRpcError = jsonRpcError; + } + + public McpError(Object error) { + super(error.toString()); + } + + public JSONRPCError getJsonRpcError() { + return jsonRpcError; + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpSchema.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpSchema.java new file mode 100644 index 000000000..ab7b5ca64 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpSchema.java @@ -0,0 +1,2039 @@ +package com.taobao.arthas.mcp.server.protocol.spec; + +import com.fasterxml.jackson.annotation.*; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.taobao.arthas.mcp.server.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Based on the JSON-RPC 2.0 specification + * and the + * Model Context Protocol Schema. + * + * @author Yeaury + */ +public final class McpSchema { + + private static final Logger logger = LoggerFactory.getLogger(McpSchema.class); + + private McpSchema() { + } + + public static final String LATEST_PROTOCOL_VERSION = "2024-11-05"; + + public static final String JSONRPC_VERSION = "2.0"; + + // --------------------------- + // Method Names + // --------------------------- + + // Lifecycle Methods + public static final String METHOD_INITIALIZE = "initialize"; + + public static final String METHOD_NOTIFICATION_INITIALIZED = "notifications/initialized"; + + public static final String METHOD_PING = "ping"; + + // Tool Methods + public static final String METHOD_TOOLS_LIST = "tools/list"; + + public static final String METHOD_TOOLS_CALL = "tools/call"; + + public static final String METHOD_NOTIFICATION_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"; + + // Resources Methods + public static final String METHOD_RESOURCES_LIST = "resources/list"; + + public static final String METHOD_RESOURCES_READ = "resources/read"; + + public static final String METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed"; + + public static final String METHOD_RESOURCES_TEMPLATES_LIST = "resources/templates/list"; + + // Prompt Methods + public static final String METHOD_PROMPT_LIST = "prompts/list"; + + public static final String METHOD_PROMPT_GET = "prompts/get"; + + public static final String METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed"; + + // Logging Methods + public static final String METHOD_LOGGING_SET_LEVEL = "logging/setLevel"; + + public static final String METHOD_NOTIFICATION_MESSAGE = "notifications/message"; + + // Roots Methods + public static final String METHOD_ROOTS_LIST = "roots/list"; + + public static final String METHOD_NOTIFICATION_ROOTS_LIST_CHANGED = "notifications/roots/list_changed"; + + // Sampling Methods + public static final String METHOD_SAMPLING_CREATE_MESSAGE = "sampling/createMessage"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + // Streamable Methods + public static final String METHOD_PROGRESS_UPDATE = "notifications/progress"; + + public static final String METHOD_STATUS_UPDATE = "notifications/status"; + + public static final String METHOD_INTERMEDIATE_RESULT = "notifications/intermediate"; + + public static final String METHOD_TOOL_ERROR = "notifications/error"; + + // --------------------------- + // JSON-RPC Error Codes + // --------------------------- + /** + * Standard error codes used in MCP JSON-RPC responses. + */ + public static final class ErrorCodes { + + /** + * The JSON received by the server is invalid. + */ + public static final int PARSE_ERROR = -32700; + + /** + * The JSON sent is not a valid Request object. + */ + public static final int INVALID_REQUEST = -32600; + + /** + * The method does not exist or is unavailable. + */ + public static final int METHOD_NOT_FOUND = -32601; + + /** + * Invalid method parameters. + */ + public static final int INVALID_PARAMS = -32602; + + /** + * Internal JSON-RPC error. + */ + public static final int INTERNAL_ERROR = -32603; + + } + + public interface Request { + } + + private static final TypeReference> MAP_TYPE_REF = new TypeReference>() { + }; + + /** + * Deserializes a JSON string into a JSONRPCMessage object. + * @param objectMapper The ObjectMapper instance to use for deserialization + * @param jsonText The JSON string to deserialize + * @return A JSONRPCMessage instance using either the {@link JSONRPCRequest}, + * {@link JSONRPCNotification}, or {@link JSONRPCResponse} classes. + * @throws IOException If there's an error during deserialization + * @throws IllegalArgumentException If the JSON structure doesn't match any known + * message type + */ + public static JSONRPCMessage deserializeJsonRpcMessage(ObjectMapper objectMapper, String jsonText) + throws IOException { + + logger.debug("Received JSON message: {}", jsonText); + + Map map = objectMapper.readValue(jsonText, MAP_TYPE_REF); + + // Determine message type based on specific JSON structure + if (map.containsKey("method") && map.containsKey("id")) { + return objectMapper.convertValue(map, JSONRPCRequest.class); + } + else if (map.containsKey("method") && !map.containsKey("id")) { + return objectMapper.convertValue(map, JSONRPCNotification.class); + } + else if (map.containsKey("result") || map.containsKey("error")) { + return objectMapper.convertValue(map, JSONRPCResponse.class); + } + + throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + jsonText); + } + + // --------------------------- + // JSON-RPC Message Types + // --------------------------- + + /** + * Represents an event message with associated event ID. + */ + public static class EventMessage { + private final String eventId; + private final JSONRPCMessage message; + + public EventMessage( + @JsonProperty("eventId") String eventId, + @JsonProperty("message") JSONRPCMessage message) { + this.eventId = eventId; + this.message = message; + } + + public String getEventId() { + return eventId; + } + + public JSONRPCMessage getMessage() { + return message; + } + } + + public interface JSONRPCMessage { + String getJsonrpc(); + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class JSONRPCRequest implements JSONRPCMessage { + private final String jsonrpc; + private final String method; + private final Object id; + private final Object params; + + public JSONRPCRequest( + @JsonProperty("jsonrpc") String jsonrpc, + @JsonProperty("method") String method, + @JsonProperty("id") Object id, + @JsonProperty("params") Object params) { + this.jsonrpc = jsonrpc; + this.method = method; + this.id = id; + this.params = params; + } + + @Override + public String getJsonrpc() { + return jsonrpc; + } + + public String getMethod() { + return method; + } + + public Object getId() { + return id; + } + + public Object getParams() { + return params; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class JSONRPCNotification implements JSONRPCMessage { + private final String jsonrpc; + private final String method; + private final Object params; + + public JSONRPCNotification( + @JsonProperty("jsonrpc") String jsonrpc, + @JsonProperty("method") String method, + @JsonProperty("params") Object params) { + this.jsonrpc = jsonrpc; + this.method = method; + this.params = params; + } + + @Override + public String getJsonrpc() { + return jsonrpc; + } + + public String getMethod() { + return method; + } + + public Object getParams() { + return params; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class JSONRPCResponse implements JSONRPCMessage { + private final String jsonrpc; + private final Object id; + private final Object result; + private final JSONRPCError error; + + public JSONRPCResponse( + @JsonProperty("jsonrpc") String jsonrpc, + @JsonProperty("id") Object id, + @JsonProperty("result") Object result, + @JsonProperty("error") JSONRPCError error) { + this.jsonrpc = jsonrpc; + this.id = id; + this.result = result; + this.error = error; + } + + @Override + public String getJsonrpc() { + return jsonrpc; + } + + public Object getId() { + return id; + } + + public Object getResult() { + return result; + } + + public JSONRPCError getError() { + return error; + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class JSONRPCError { + private final int code; + private final String message; + private final Object data; + + public JSONRPCError( + @JsonProperty("code") int code, + @JsonProperty("message") String message, + @JsonProperty("data") Object data) { + this.code = code; + this.message = message; + this.data = data; + } + + public int getCode() { + return code; + } + + public String getMessage() { + return message; + } + + public Object getData() { + return data; + } + } + } + + // --------------------------- + // Initialization + // --------------------------- + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class InitializeRequest implements Request { + private final String protocolVersion; + private final ClientCapabilities capabilities; + private final Implementation clientInfo; + + public InitializeRequest( + @JsonProperty("protocolVersion") String protocolVersion, + @JsonProperty("capabilities") ClientCapabilities capabilities, + @JsonProperty("clientInfo") Implementation clientInfo) { + this.protocolVersion = protocolVersion; + this.capabilities = capabilities; + this.clientInfo = clientInfo; + } + + public String getProtocolVersion() { + return protocolVersion; + } + + public ClientCapabilities getCapabilities() { + return capabilities; + } + + public Implementation getClientInfo() { + return clientInfo; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class InitializeResult { + private final String protocolVersion; + private final ServerCapabilities capabilities; + private final Implementation serverInfo; + private final String instructions; + + public InitializeResult( + @JsonProperty("protocolVersion") String protocolVersion, + @JsonProperty("capabilities") ServerCapabilities capabilities, + @JsonProperty("serverInfo") Implementation serverInfo, + @JsonProperty("instructions") String instructions) { + this.protocolVersion = protocolVersion; + this.capabilities = capabilities; + this.serverInfo = serverInfo; + this.instructions = instructions; + } + + public String getProtocolVersion() { + return protocolVersion; + } + + public ServerCapabilities getCapabilities() { + return capabilities; + } + + public Implementation getServerInfo() { + return serverInfo; + } + + public String getInstructions() { + return instructions; + } + } + + /** + * Clients can implement additional features to enrich connected MCP servers with + * additional capabilities. These capabilities can be used to extend the functionality + * of the server, or to provide additional information to the server about the + * client's capabilities. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ClientCapabilities { + + private final Map experimental; + private final RootCapabilities roots; + private final Sampling sampling; + + public ClientCapabilities( + @JsonProperty("experimental") Map experimental, + @JsonProperty("roots") RootCapabilities roots, + @JsonProperty("sampling") Sampling sampling) { + this.experimental = experimental; + this.roots = roots; + this.sampling = sampling; + } + + /** + * Roots define the boundaries of where servers can operate within the filesystem, + * allowing them to understand which directories and files they have access to. + * Servers can request the list of roots from supporting clients and + * receive notifications when that list changes. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RootCapabilities { + private final Boolean listChanged; + + public RootCapabilities( + @JsonProperty("listChanged") Boolean listChanged) { + this.listChanged = listChanged; + } + + public Boolean getListChanged() { + return listChanged; + } + } + + /** + * Provides a standardized way for servers to request LLM + * sampling ("completions" or "generations") from language + * models via clients. This flow allows clients to maintain + * control over model access, selection, and permissions + * while enabling servers to leverage AI capabilities—with + * no server API keys necessary. Servers can request text or + * image-based interactions and optionally include context + * from MCP servers in their prompts. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public static class Sampling { + } + + public Map getExperimental() { + return experimental; + } + + public RootCapabilities getRoots() { + return roots; + } + + public Sampling getSampling() { + return sampling; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private Map experimental; + private RootCapabilities roots; + private Sampling sampling; + + public Builder experimental(Map experimental) { + this.experimental = experimental; + return this; + } + + public Builder roots(Boolean listChanged) { + this.roots = new RootCapabilities(listChanged); + return this; + } + + public Builder sampling() { + this.sampling = new Sampling(); + return this; + } + + public ClientCapabilities build() { + return new ClientCapabilities(experimental, roots, sampling); + } + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ServerCapabilities { + private final Map experimental; + private final LoggingCapabilities logging; + private final PromptCapabilities prompts; + private final ResourceCapabilities resources; + private final ToolCapabilities tools; + + public ServerCapabilities( + @JsonProperty("experimental") Map experimental, + @JsonProperty("logging") LoggingCapabilities logging, + @JsonProperty("prompts") PromptCapabilities prompts, + @JsonProperty("resources") ResourceCapabilities resources, + @JsonProperty("tools") ToolCapabilities tools) { + this.experimental = experimental; + this.logging = logging; + this.prompts = prompts; + this.resources = resources; + this.tools = tools; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private Map experimental; + private LoggingCapabilities logging; + private PromptCapabilities prompts; + private ResourceCapabilities resources; + private ToolCapabilities tools; + + public Builder experimental(Map experimental) { + this.experimental = experimental; + return this; + } + + public Builder logging(LoggingCapabilities logging) { + this.logging = logging; + return this; + } + + public Builder prompts(PromptCapabilities prompts) { + this.prompts = prompts; + return this; + } + + public Builder resources(ResourceCapabilities resources) { + this.resources = resources; + return this; + } + + public Builder tools(ToolCapabilities tools) { + this.tools = tools; + return this; + } + + public ServerCapabilities build() { + return new ServerCapabilities(experimental, logging, prompts, resources, tools); + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public static class LoggingCapabilities { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public static class PromptCapabilities { + private final Boolean listChanged; + + public PromptCapabilities(@JsonProperty("listChanged") Boolean listChanged) { + this.listChanged = listChanged; + } + + public Boolean getListChanged() { + return listChanged; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public static class ResourceCapabilities { + private final Boolean subscribe; + private final Boolean listChanged; + + public ResourceCapabilities( + @JsonProperty("subscribe") Boolean subscribe, + @JsonProperty("listChanged") Boolean listChanged) { + this.subscribe = subscribe; + this.listChanged = listChanged; + } + + public Boolean getSubscribe() { + return subscribe; + } + + public Boolean getListChanged() { + return listChanged; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public static class ToolCapabilities { + private final Boolean listChanged; + + public ToolCapabilities(@JsonProperty("listChanged") Boolean listChanged) { + this.listChanged = listChanged; + } + + public Boolean getListChanged() { + return listChanged; + } + } + + public Map getExperimental() { + return experimental; + } + + public LoggingCapabilities getLogging() { + return logging; + } + + public PromptCapabilities getPrompts() { + return prompts; + } + + public ResourceCapabilities getResources() { + return resources; + } + + public ToolCapabilities getTools() { + return tools; + } + } + + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Implementation { + private final String name; + private final String version; + + public Implementation( + @JsonProperty("name") String name, + @JsonProperty("version") String version) { + this.name = name; + this.version = version; + } + + public String getName() { + return name; + } + + public String getVersion() { + return version; + } + } + + // Existing Enums and Base Types + public enum Role { + @JsonProperty("user") USER, + @JsonProperty("assistant") ASSISTANT + } + + public enum StopReason { + @JsonProperty("stop") STOP, + @JsonProperty("length") LENGTH, + @JsonProperty("content_filter") CONTENT_FILTER + } + + public enum ContextInclusionStrategy { + @JsonProperty("none") NONE, + @JsonProperty("all") ALL, + @JsonProperty("relevant") RELEVANT + } + + // --------------------------- + // Resource Interfaces + // --------------------------- + /** + * Base for objects that include optional annotations for the client. The client can + * use annotations to inform how objects are used or displayed + */ + public interface Annotated { + + Annotations annotations(); + + } + + /** + * Optional annotations for the client. The client can use annotations to inform how + * objects are used or displayed. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Annotations { + private final List audience; + private final Double priority; + + public Annotations( + @JsonProperty("audience") List audience, + @JsonProperty("priority") Double priority) { + this.audience = audience; + this.priority = priority; + } + + public List getAudience() { + return audience; + } + + public Double getPriority() { + return priority; + } + } + + /** + * A known resource that the server is capable of reading. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Resource implements Annotated { + private final String uri; + private final String name; + private final String description; + private final String mimeType; + private final Annotations annotations; + + public Resource( + @JsonProperty("uri") String uri, + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("annotations") Annotations annotations) { + this.uri = uri; + this.name = name; + this.description = description; + this.mimeType = mimeType; + this.annotations = annotations; + } + + @Override + public Annotations annotations() { + return annotations; + } + + public String getUri() { + return uri; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getMimeType() { + return mimeType; + } + } + + /** + * Resource templates allow servers to expose parameterized resources using URI + * templates. + * + * @see RFC 6570 + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ResourceTemplate implements Annotated { + private final String uriTemplate; + private final String name; + private final String description; + private final String mimeType; + private final Annotations annotations; + + public ResourceTemplate( + @JsonProperty("uriTemplate") String uriTemplate, + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("annotations") Annotations annotations) { + this.uriTemplate = uriTemplate; + this.name = name; + this.description = description; + this.mimeType = mimeType; + this.annotations = annotations; + } + + @Override + public Annotations annotations() { + return annotations; + } + + public String getUriTemplate() { + return uriTemplate; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getMimeType() { + return mimeType; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ListResourcesResult { + private final List resources; + private final String nextCursor; + + public ListResourcesResult( + @JsonProperty("resources") List resources, + @JsonProperty("nextCursor") String nextCursor) { + this.resources = resources; + this.nextCursor = nextCursor; + } + + public List getResources() { + return resources; + } + + public String getNextCursor() { + return nextCursor; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ListResourceTemplatesResult { + private final List resourceTemplates; + private final String nextCursor; + + public ListResourceTemplatesResult( + @JsonProperty("resourceTemplates") List resourceTemplates, + @JsonProperty("nextCursor") String nextCursor) { + this.resourceTemplates = resourceTemplates; + this.nextCursor = nextCursor; + } + + public List getResourceTemplates() { + return resourceTemplates; + } + + public String getNextCursor() { + return nextCursor; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ReadResourceRequest { + private final String uri; + + public ReadResourceRequest( + @JsonProperty("uri") String uri) { + this.uri = uri; + } + + public String getUri() { + return uri; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ReadResourceResult { + private final List contents; + + public ReadResourceResult( + @JsonProperty("contents") List contents) { + this.contents = contents; + } + + public List getContents() { + return contents; + } + } + + /** + * Sent from the client to request resources/updated notifications from the server + * whenever a particular resource changes. + * + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SubscribeRequest { + private final String uri; + + public SubscribeRequest( + @JsonProperty("uri") String uri) { + this.uri = uri; + } + + public String getUri() { + return uri; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class UnsubscribeRequest { + private final String uri; + + public UnsubscribeRequest( + @JsonProperty("uri") String uri) { + this.uri = uri; + } + + public String getUri() { + return uri; + } + } + + /** + * The contents of a specific resource or sub-resource. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION, include = As.PROPERTY) + @JsonSubTypes({ @JsonSubTypes.Type(value = TextResourceContents.class, name = "text"), + @JsonSubTypes.Type(value = BlobResourceContents.class, name = "blob") }) + public interface ResourceContents { + + /** + * The URI of this resource. + * @return the URI of this resource. + */ + String uri(); + + /** + * The MIME type of this resource. + * @return the MIME type of this resource. + */ + String mimeType(); + + } + + /** + * Text contents of a resource. + * + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TextResourceContents implements ResourceContents { + private final String uri; + private final String mimeType; + private final String text; + + public TextResourceContents( + @JsonProperty("uri") String uri, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("text") String text) { + this.uri = uri; + this.mimeType = mimeType; + this.text = text; + } + + @Override + public String uri() { + return uri; + } + + @Override + public String mimeType() { + return mimeType; + } + + public String getText() { + return text; + } + } + + /** + * Binary contents of a resource. + * + * This must only be set if the resource can actually be represented as binary data + * (not text). + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class BlobResourceContents implements ResourceContents { + private final String uri; + private final String mimeType; + private final String blob; + + public BlobResourceContents( + @JsonProperty("uri") String uri, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("blob") String blob) { + this.uri = uri; + this.mimeType = mimeType; + this.blob = blob; + } + + @Override + public String uri() { + return uri; + } + + @Override + public String mimeType() { + return mimeType; + } + + public String getBlob() { + return blob; + } + } + + // --------------------------- + // Prompt Interfaces + // --------------------------- + /** + * A prompt or prompt template that the server offers. + * + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Prompt { + private final String name; + private final String description; + private final List arguments; + + public Prompt( + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("arguments") List arguments) { + this.name = name; + this.description = description; + this.arguments = arguments; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public List getArguments() { + return arguments; + } + } + + /** + * Describes an argument that a prompt can accept. + * + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class PromptArgument { + private final String name; + private final String description; + private final Boolean required; + + public PromptArgument( + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("required") Boolean required) { + this.name = name; + this.description = description; + this.required = required; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Boolean getRequired() { + return required; + } + } + + /** + * Describes a message returned as part of a prompt. + * + * This is similar to `SamplingMessage`, but also supports the embedding of resources + * from the MCP server. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class PromptMessage { + private final Role role; + private final Content content; + + public PromptMessage( + @JsonProperty("role") Role role, + @JsonProperty("content") Content content) { + this.role = role; + this.content = content; + } + + public Role getRole() { + return role; + } + + public Content getContent() { + return content; + } + } + + /** + * The server's response to a prompts/list request from the client. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ListPromptsResult { + private final List prompts; + private final String nextCursor; + + public ListPromptsResult( + @JsonProperty("prompts") List prompts, + @JsonProperty("nextCursor") String nextCursor) { + this.prompts = prompts; + this.nextCursor = nextCursor; + } + + public List getPrompts() { + return prompts; + } + + public String getNextCursor() { + return nextCursor; + } + } + + /** + * Used by the client to get a prompt provided by the server. + * + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GetPromptRequest implements Request { + private final String name; + private final Map arguments; + + public GetPromptRequest( + @JsonProperty("name") String name, + @JsonProperty("arguments") Map arguments) { + this.name = name; + this.arguments = arguments; + } + + public String getName() { + return name; + } + + public Map getArguments() { + return arguments; + } + } + + /** + * The server's response to a prompts/get request from the client. + * + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GetPromptResult { + private final String description; + private final List messages; + + public GetPromptResult( + @JsonProperty("description") String description, + @JsonProperty("messages") List messages) { + this.description = description; + this.messages = messages; + } + + public String getDescription() { + return description; + } + + public List getMessages() { + return messages; + } + } + + // --------------------------- + // Tool Interfaces + // --------------------------- + /** + * The server's response to a tools/list request from the client. + * + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ListToolsResult { + private final List tools; + private final String nextCursor; + + public ListToolsResult( + @JsonProperty("tools") List tools, + @JsonProperty("nextCursor") String nextCursor) { + this.tools = tools; + this.nextCursor = nextCursor; + } + + public List getTools() { + return tools; + } + + public String getNextCursor() { + return nextCursor; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class JsonSchema { + private final String type; + private final Map properties; + private final List required; + private final Boolean additionalProperties; + + public JsonSchema( + @JsonProperty("type") String type, + @JsonProperty("properties") Map properties, + @JsonProperty("required") List required, + @JsonProperty("additionalProperties") Boolean additionalProperties) { + this.type = type; + this.properties = properties; + this.required = required; + this.additionalProperties = additionalProperties; + } + + public String getType() { + return type; + } + + public Map getProperties() { + return properties; + } + + public List getRequired() { + return required; + } + + public Boolean getAdditionalProperties() { + return additionalProperties; + } + } + + /** + * Represents a tool that the server provides. Tools enable servers to expose + * executable functionality to the system. Through these tools, you can interact with + * external systems, perform computations, and take actions in the real world. + * + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Tool { + private final String name; + private final String description; + private final JsonSchema inputSchema; + + public Tool( + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("inputSchema") JsonSchema inputSchema) { + this.name = name; + this.description = description; + this.inputSchema = inputSchema; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public JsonSchema getInputSchema() { + return inputSchema; + } + } + + private static JsonSchema parseSchema(String schema) { + try { + return OBJECT_MAPPER.readValue(schema, JsonSchema.class); + } + catch (IOException e) { + throw new IllegalArgumentException("Invalid schema: " + schema, e); + } + } + + /** + * Used by the client to call a tool provided by the server. + * + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CallToolRequest implements Request { + private final String name; + private final Map arguments; + + public CallToolRequest( + @JsonProperty("name") String name, + @JsonProperty("arguments") Map arguments) { + this.name = name; + this.arguments = arguments; + } + + public CallToolRequest(String name, String jsonArguments) { + this(name, parseJsonArguments(jsonArguments)); + } + + private static Map parseJsonArguments(String jsonArguments) { + try { + return OBJECT_MAPPER.readValue(jsonArguments, MAP_TYPE_REF); + } + catch (IOException e) { + throw new IllegalArgumentException("Invalid arguments: " + jsonArguments, e); + } + } + + public String getName() { + return name; + } + + public Map getArguments() { + return arguments; + } + } + + /** + * The server's response to a tools/call request from the client. + * + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CallToolResult { + private final List content; + private final Boolean isError; + + public CallToolResult( + @JsonProperty("content") List content, + @JsonProperty("isError") Boolean isError) { + this.content = content; + this.isError = isError; + } + + public CallToolResult(String content, Boolean isError) { + this(Collections.singletonList(new TextContent(content)), isError); + } + + public List getContent() { + return content; + } + + public Boolean getIsError() { + return isError; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List content = new ArrayList<>(); + private Boolean isError; + + public Builder content(List content) { + Assert.notNull(content, "content must not be null"); + this.content = content; + return this; + } + + public Builder textContent(List textContent) { + Assert.notNull(textContent, "textContent must not be null"); + textContent.stream() + .map(TextContent::new) + .forEach(this.content::add); + return this; + } + + public Builder addContent(Content contentItem) { + Assert.notNull(contentItem, "contentItem must not be null"); + if (this.content == null) { + this.content = new ArrayList<>(); + } + this.content.add(contentItem); + return this; + } + + public Builder addTextContent(String text) { + Assert.notNull(text, "text must not be null"); + return addContent(new TextContent(text)); + } + + public Builder isError(Boolean isError) { + Assert.notNull(isError, "isError must not be null"); + this.isError = isError; + return this; + } + + public CallToolResult build() { + return new CallToolResult(content, isError); + } + } + } + + // --------------------------- + // Sampling Interfaces + // --------------------------- + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ModelPreferences { + private final List hints; + private final Double costPriority; + private final Double speedPriority; + private final Double intelligencePriority; + + public ModelPreferences( + @JsonProperty("hints") List hints, + @JsonProperty("costPriority") Double costPriority, + @JsonProperty("speedPriority") Double speedPriority, + @JsonProperty("intelligencePriority") Double intelligencePriority) { + this.hints = hints; + this.costPriority = costPriority; + this.speedPriority = speedPriority; + this.intelligencePriority = intelligencePriority; + } + + public List getHints() { + return hints; + } + + public Double getCostPriority() { + return costPriority; + } + + public Double getSpeedPriority() { + return speedPriority; + } + + public Double getIntelligencePriority() { + return intelligencePriority; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ModelHint { + private final String name; + + public ModelHint( + @JsonProperty("name") String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SamplingMessage { + private final Role role; + private final Content content; + + public SamplingMessage( + @JsonProperty("role") Role role, + @JsonProperty("content") Content content) { + this.role = role; + this.content = content; + } + + public Role getRole() { + return role; + } + + public Content getContent() { + return content; + } + } + + // Sampling and Message Creation + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CreateMessageRequest implements Request { + private final List messages; + private final ModelPreferences modelPreferences; + private final String systemPrompt; + private final ContextInclusionStrategy includeContext; + private final Double temperature; + private final int maxTokens; + private final List stopSequences; + private final Map metadata; + + public CreateMessageRequest( + @JsonProperty("messages") List messages, + @JsonProperty("modelPreferences") ModelPreferences modelPreferences, + @JsonProperty("systemPrompt") String systemPrompt, + @JsonProperty("includeContext") ContextInclusionStrategy includeContext, + @JsonProperty("temperature") Double temperature, + @JsonProperty("maxTokens") int maxTokens, + @JsonProperty("stopSequences") List stopSequences, + @JsonProperty("metadata") Map metadata) { + this.messages = messages; + this.modelPreferences = modelPreferences; + this.systemPrompt = systemPrompt; + this.includeContext = includeContext; + this.temperature = temperature; + this.maxTokens = maxTokens; + this.stopSequences = stopSequences; + this.metadata = metadata; + } + + public List getMessages() { + return messages; + } + + public ModelPreferences getModelPreferences() { + return modelPreferences; + } + + public String getSystemPrompt() { + return systemPrompt; + } + + public ContextInclusionStrategy getIncludeContext() { + return includeContext; + } + + public Double getTemperature() { + return temperature; + } + + public int getMaxTokens() { + return maxTokens; + } + + public List getStopSequences() { + return stopSequences; + } + + public Map getMetadata() { + return metadata; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CreateMessageResult { + private final Role role; + private final Content content; + private final String model; + private final StopReason stopReason; + + public CreateMessageResult( + @JsonProperty("role") Role role, + @JsonProperty("content") Content content, + @JsonProperty("model") String model, + @JsonProperty("stopReason") StopReason stopReason) { + this.role = role; + this.content = content; + this.model = model; + this.stopReason = stopReason; + } + + public Role getRole() { + return role; + } + + public Content getContent() { + return content; + } + + public String getModel() { + return model; + } + + public StopReason getStopReason() { + return stopReason; + } + } + + // --------------------------- + // Pagination Interfaces + // --------------------------- + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class PaginatedRequest { + private final String cursor; + + public PaginatedRequest( + @JsonProperty("cursor") String cursor) { + this.cursor = cursor; + } + + public String getCursor() { + return cursor; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class PaginatedResult { + private final String nextCursor; + + public PaginatedResult( + @JsonProperty("nextCursor") String nextCursor) { + this.nextCursor = nextCursor; + } + + public String getNextCursor() { + return nextCursor; + } + } + + // --------------------------- + // Progress and Logging + // --------------------------- + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ProgressNotification { + private final String progressToken; + private final double progress; + private final Double total; + + public ProgressNotification( + @JsonProperty("progressToken") String progressToken, + @JsonProperty("progress") double progress, + @JsonProperty("total") Double total) { + this.progressToken = progressToken; + this.progress = progress; + this.total = total; + } + + public String getProgressToken() { + return progressToken; + } + + public double getProgress() { + return progress; + } + + public Double getTotal() { + return total; + } + } + + /** + * The Model Context Protocol (MCP) provides a standardized way for servers to send + * structured log messages to clients. Clients can control logging verbosity by + * setting minimum log levels, with servers sending notifications containing severity + * levels, optional logger names, and arbitrary JSON-serializable data. + * + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class LoggingMessageNotification { + private final LoggingLevel level; + private final String logger; + private final String data; + + public LoggingMessageNotification( + @JsonProperty("level") LoggingLevel level, + @JsonProperty("logger") String logger, + @JsonProperty("data") String data) { + this.level = level; + this.logger = logger; + this.data = data; + } + + public LoggingLevel getLevel() { + return level; + } + + public String getLogger() { + return logger; + } + + public String getData() { + return data; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private LoggingLevel level = LoggingLevel.INFO; + private String logger = "server"; + private String data; + + public Builder level(LoggingLevel level) { + this.level = level; + return this; + } + + public Builder logger(String logger) { + this.logger = logger; + return this; + } + + public Builder data(String data) { + this.data = data; + return this; + } + + public LoggingMessageNotification build() { + return new LoggingMessageNotification(level, logger, data); + } + } + } + + public enum LoggingLevel { + @JsonProperty("debug") DEBUG(0), + @JsonProperty("info") INFO(1), + @JsonProperty("notice") NOTICE(2), + @JsonProperty("warning") WARNING(3), + @JsonProperty("error") ERROR(4), + @JsonProperty("critical") CRITICAL(5), + @JsonProperty("alert") ALERT(6), + @JsonProperty("emergency") EMERGENCY(7); + + private final int level; + + LoggingLevel(int level) { + this.level = level; + } + + public int level() { + return level; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SetLevelRequest { + private final LoggingLevel level; + + public SetLevelRequest( + @JsonProperty("level") LoggingLevel level) { + this.level = level; + } + + public LoggingLevel getLevel() { + return level; + } + } + + // --------------------------- + // Autocomplete + // --------------------------- + public enum CompleteArgument { + @JsonProperty("name") NAME, + @JsonProperty("description") DESCRIPTION, + @JsonProperty("uri") URI, + @JsonProperty("mimeType") MIME_TYPE + } + + public static class CompleteRequest implements Request { + public static class PromptOrResourceReference { + private final String type; + + public PromptOrResourceReference( + @JsonProperty("type") String type) { + this.type = type; + } + + public String getType() { + return type; + } + } + + public static class PromptReference extends PromptOrResourceReference { + private final String name; + + public PromptReference( + @JsonProperty("type") String type, + @JsonProperty("name") String name) { + super(type); + this.name = name; + } + + public String getName() { + return name; + } + } + + public static class ResourceReference extends PromptOrResourceReference { + private final String uri; + + public ResourceReference( + @JsonProperty("type") String type, + @JsonProperty("uri") String uri) { + super(type); + this.uri = uri; + } + + public String getUri() { + return uri; + } + } + + private final PromptOrResourceReference ref; + private final CompleteArgument argument; + + public CompleteRequest( + @JsonProperty("ref") PromptOrResourceReference ref, + @JsonProperty("argument") CompleteArgument argument) { + this.ref = ref; + this.argument = argument; + } + + public PromptOrResourceReference getRef() { + return ref; + } + + public CompleteArgument getArgument() { + return argument; + } + } + + public static class CompleteResult { + private final CompleteCompletion completion; + + public CompleteResult( + @JsonProperty("completion") CompleteCompletion completion) { + this.completion = completion; + } + + public CompleteCompletion getCompletion() { + return completion; + } + } + + public static class CompleteCompletion { + private final List values; + private final Integer total; + private final Boolean hasMore; + + public CompleteCompletion( + @JsonProperty("values") List values, + @JsonProperty("total") Integer total, + @JsonProperty("hasMore") Boolean hasMore) { + this.values = values; + this.total = total; + this.hasMore = hasMore; + } + + public List getValues() { + return values; + } + + public Integer getTotal() { + return total; + } + + public Boolean getHasMore() { + return hasMore; + } + } + + // --------------------------- + // Content Types + // --------------------------- + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = As.PROPERTY, property = "type") + @JsonSubTypes({ @JsonSubTypes.Type(value = TextContent.class, name = "text"), + @JsonSubTypes.Type(value = ImageContent.class, name = "image"), + @JsonSubTypes.Type(value = EmbeddedResource.class, name = "resource") }) + public interface Content { + + default String type() { + if (this instanceof TextContent) { + return "text"; + } + else if (this instanceof ImageContent) { + return "image"; + } + else if (this instanceof EmbeddedResource) { + return "resource"; + } + throw new IllegalArgumentException("Unknown content type: " + this); + } + + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TextContent implements Content { + private final List audience; + private final Double priority; + private final String text; + + public TextContent( + @JsonProperty("audience") List audience, + @JsonProperty("priority") Double priority, + @JsonProperty("text") String text) { + this.audience = audience; + this.priority = priority; + this.text = text; + } + + public TextContent(String content) { + this(null, null, content); + } + + public List getAudience() { + return audience; + } + + public Double getPriority() { + return priority; + } + + public String getText() { + return text; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ImageContent implements Content { + private final List audience; + private final Double priority; + private final String data; + private final String mimeType; + + public ImageContent( + @JsonProperty("audience") List audience, + @JsonProperty("priority") Double priority, + @JsonProperty("data") String data, + @JsonProperty("mimeType") String mimeType) { + this.audience = audience; + this.priority = priority; + this.data = data; + this.mimeType = mimeType; + } + + @Override + public String type() { + return "image"; + } + + public List getAudience() { + return audience; + } + + public Double getPriority() { + return priority; + } + + public String getData() { + return data; + } + + public String getMimeType() { + return mimeType; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class EmbeddedResource implements Content { + private final List audience; + private final Double priority; + private final ResourceContents resource; + + public EmbeddedResource( + @JsonProperty("audience") List audience, + @JsonProperty("priority") Double priority, + @JsonProperty("resource") ResourceContents resource) { + this.audience = audience; + this.priority = priority; + this.resource = resource; + } + + @Override + public String type() { + return "resource"; + } + + public List getAudience() { + return audience; + } + + public Double getPriority() { + return priority; + } + + public ResourceContents getResource() { + return resource; + } + } + + // --------------------------- + // Roots + // --------------------------- + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Root { + private final String uri; + private final String name; + + public Root( + @JsonProperty("uri") String uri, + @JsonProperty("name") String name) { + this.uri = uri; + this.name = name; + } + + public String getUri() { + return uri; + } + + public String getName() { + return name; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ListRootsResult { + private final List roots; + + public ListRootsResult( + @JsonProperty("roots") List roots) { + this.roots = roots; + } + + public List getRoots() { + return roots; + } + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpServerSession.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpServerSession.java new file mode 100644 index 000000000..234fced1c --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpServerSession.java @@ -0,0 +1,433 @@ +package com.taobao.arthas.mcp.server.protocol.spec; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange; +import io.netty.channel.Channel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class McpServerSession implements McpSession { + + private static final Logger logger = LoggerFactory.getLogger(McpServerSession.class); + + private final ConcurrentHashMap> pendingResponses = new ConcurrentHashMap<>(); + + private final String id; + + private final Duration requestTimeout; + + private final AtomicLong requestCounter = new AtomicLong(0); + + private final InitRequestHandler initRequestHandler; + + private final InitNotificationHandler initNotificationHandler; + + private final Map> requestHandlers; + + private final Map notificationHandlers; + + private final McpServerTransport transport; + + private final CompletableFuture exchangeFuture = new CompletableFuture<>(); + + private final AtomicReference clientCapabilities = new AtomicReference<>(); + + private final AtomicReference clientInfo = new AtomicReference<>(); + + private final Channel channel; + + private static final int STATE_UNINITIALIZED = 0; + + private static final int STATE_INITIALIZING = 1; + + private static final int STATE_INITIALIZED = 2; + + private final AtomicInteger state = new AtomicInteger(STATE_UNINITIALIZED); + + /** + * Create a new server session. + * @param id Session ID + * @param requestTimeout request timeout + * @param transport layer used + * @param initHandler handlers that handle initialization requests + * @param initNotificationHandler handles the handler that initializes the notification + * @param requestHandlers request processor mappings + * @param notificationHandlers notification handler mapping + * @param channel Netty's Channel object + */ + public McpServerSession(String id, Duration requestTimeout, McpServerTransport transport, + InitRequestHandler initHandler, InitNotificationHandler initNotificationHandler, + Map> requestHandlers, Map notificationHandlers, + Channel channel) { + this.id = id; + this.requestTimeout = requestTimeout; + this.transport = transport; + this.initRequestHandler = initHandler; + this.initNotificationHandler = initNotificationHandler; + this.requestHandlers = requestHandlers; + this.notificationHandlers = notificationHandlers; + this.channel = channel; + } + + public String getId() { + return this.id; + } + + /** + * Called after successfully initializing the sequence between the client and server, + * including client capabilities and information. + * @param clientCapabilities The capabilities provided by the connected client + * @param clientInfo Information about the connected client + */ + public void init(McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo) { + this.clientCapabilities.lazySet(clientCapabilities); + this.clientInfo.lazySet(clientInfo); + } + + private String generateRequestId() { + return this.id + "-" + this.requestCounter.getAndIncrement(); + } + + @Override + public CompletableFuture sendRequest(String method, Object requestParams, TypeReference typeRef) { + String requestId = this.generateRequestId(); + + CompletableFuture result = new CompletableFuture<>(); + CompletableFuture responseFuture = new CompletableFuture<>(); + + this.pendingResponses.put(requestId, responseFuture); + + McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method, + requestId, requestParams); + + // 使用Netty的Channel进行异步发送 + channel.eventLoop().execute(() -> { + transport.sendMessage(jsonrpcRequest).exceptionally(error -> { + pendingResponses.remove(requestId); + responseFuture.completeExceptionally(error); + return null; + }); + }); + + // 设置超时处理 + channel.eventLoop().schedule(() -> { + if (!responseFuture.isDone()) { + pendingResponses.remove(requestId); + responseFuture.completeExceptionally( + new RuntimeException("Request timed out after " + requestTimeout.toMillis() + "ms")); + } + }, requestTimeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS); + + // 处理响应结果 + responseFuture.thenAccept(jsonRpcResponse -> { + if (jsonRpcResponse.getError() != null) { + result.completeExceptionally(new McpError(jsonRpcResponse.getError())); + } + else { + if (typeRef.getType().equals(Void.class)) { + result.complete(null); + } + else { + result.complete(this.transport.unmarshalFrom(jsonRpcResponse.getResult(), typeRef)); + } + } + }).exceptionally(error -> { + result.completeExceptionally(error); + return null; + }); + + return result; + } + + @Override + public CompletableFuture sendNotification(String method, Object params) { + McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, + method, params); + + return CompletableFuture.supplyAsync(() -> { + channel.eventLoop().execute(() -> { + transport.sendMessage(jsonrpcNotification); + }); + return null; + }, runnable -> channel.eventLoop().execute(runnable)); + } + + public CompletableFuture handle(McpSchema.JSONRPCMessage message) { + CompletableFuture result = new CompletableFuture<>(); + + try { + if (message instanceof McpSchema.JSONRPCResponse) { + handleResponse((McpSchema.JSONRPCResponse) message, result); + } + else if (message instanceof McpSchema.JSONRPCRequest) { + handleRequest((McpSchema.JSONRPCRequest) message, result); + } + else if (message instanceof McpSchema.JSONRPCNotification) { + handleNotification((McpSchema.JSONRPCNotification) message, result); + } + else { + logger.warn("Received unknown message type: {}", message); + result.complete(null); + } + } + catch (Exception e) { + logger.error("Error processing message", e); + result.completeExceptionally(e); + } + + return result; + } + + private void handleResponse(McpSchema.JSONRPCResponse response, CompletableFuture result) { + logger.debug("Received response: {}", response); + CompletableFuture sink = pendingResponses.remove(response.getId()); + if (sink == null) { + logger.warn("Received unexpected response with unknown ID: {}", response.getId()); + } + else { + sink.complete(response); + } + result.complete(null); + } + + private void handleRequest(McpSchema.JSONRPCRequest request, CompletableFuture result) { + logger.debug("Received request: {}", request); + handleIncomingRequest(request) + .thenCompose(this.transport::sendMessage) + .thenAccept(v -> result.complete(null)) + .exceptionally(error -> { + McpSchema.JSONRPCResponse errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), null, + new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, + error.getMessage(), null)); + + channel.eventLoop().execute(() -> { + this.transport.sendMessage(errorResponse) + .thenRun(() -> result.complete(null)) + .exceptionally(e -> { + result.completeExceptionally(e); + return null; + }); + }); + return null; + }); + } + + private void handleNotification(McpSchema.JSONRPCNotification notification, CompletableFuture result) { + logger.debug("Received notification: {}", notification); + handleIncomingNotification(notification) + .thenAccept(v -> result.complete(null)) + .exceptionally(error -> { + logger.error("Error processing notification: {}", error.getMessage()); + result.complete(null); + return null; + }); + } + + private CompletableFuture handleIncomingRequest(McpSchema.JSONRPCRequest request) { + String method = request.getMethod(); + Object params = request.getParams(); + + if (McpSchema.METHOD_INITIALIZE.equals(method)) { + if (this.state.compareAndSet(STATE_UNINITIALIZED, STATE_INITIALIZING)) { + try { + McpSchema.InitializeRequest initRequest = this.transport.unmarshalFrom(params, + new TypeReference() { + }); + return this.initRequestHandler.handle(initRequest).thenApply(result -> { + this.state.set(STATE_INITIALIZED); + this.init(initRequest.getCapabilities(), initRequest.getClientInfo()); + + if (!this.exchangeFuture.isDone()) { + McpNettyServerExchange exchange = new McpNettyServerExchange(this, + initRequest.getCapabilities(), initRequest.getClientInfo()); + this.exchangeFuture.complete(exchange); + } + + return new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), result, null); + }).exceptionally(error -> { + this.state.set(STATE_UNINITIALIZED); + return new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), null, + new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, + error.getMessage(), null)); + }); + } + catch (Exception e) { + this.state.set(STATE_UNINITIALIZED); + CompletableFuture future = new CompletableFuture<>(); + future.complete(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), null, + new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INVALID_PARAMS, + "invalid initialization parameter: " + e.getMessage(), null))); + return future; + } + } + else { + CompletableFuture future = new CompletableFuture<>(); + future.complete(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), null, + new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INVALID_REQUEST, "the session has been initialized", + null))); + return future; + } + } + + // check if the session is initialized + if (this.state.get() != STATE_INITIALIZED) { + CompletableFuture future = new CompletableFuture<>(); + future.complete(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), null, + new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INVALID_REQUEST, "the session has not been initialized", null))); + return future; + } + + // handle regular requests + RequestHandler handler = this.requestHandlers.get(method); + if (handler == null) { + MethodNotFoundError error = getMethodNotFoundError(method); + CompletableFuture future = new CompletableFuture<>(); + future.complete(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), null, + new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND, error.getMessage(), + error.getData()))); + return future; + } + + // get the swap object and process the request + return this.exchangeFuture.thenCompose(exchange -> { + try { + @SuppressWarnings("unchecked") + RequestHandler typedHandler = (RequestHandler) handler; + return typedHandler.handle(exchange, params) + .thenApply(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), result, + null)) + .exceptionally(error -> { + Throwable cause = error.getCause(); + if (cause instanceof McpError) { + McpError mcpError = (McpError) cause; + return new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), null, + mcpError.getJsonRpcError()); + } + else { + return new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), null, + new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, + error.getMessage(), null)); + } + }); + } + catch (Exception e) { + CompletableFuture future = new CompletableFuture<>(); + future.complete(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.getId(), null, + new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, e.getMessage(), + null))); + return future; + } + }); + } + + /** + * Handle incoming JSON-RPC notifications, routing them to the appropriate handler. + * @param notification incoming JSON-RPC notification + * @return a CompletableFuture that completes when the notification processing is finished. + * The CompletableFuture completes normally if the processing succeeds, or exceptionally + * if an error occurs during processing. + */ + private CompletableFuture handleIncomingNotification(McpSchema.JSONRPCNotification notification) { + String method = notification.getMethod(); + Object params = notification.getParams(); + + if (McpSchema.METHOD_NOTIFICATION_INITIALIZED.equals(method)) { + return this.initNotificationHandler.handle(); + } + + if (this.state.get() != STATE_INITIALIZED) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new IllegalStateException("the session has not been initialized")); + return future; + } + + NotificationHandler handler = this.notificationHandlers.get(method); + if (handler == null) { + logger.warn("No handler found for method: {}", method); + CompletableFuture future = new CompletableFuture<>(); + future.complete(null); + return future; + } + + return this.exchangeFuture.thenCompose(exchange -> handler.handle(exchange, params)); + } + + public static class MethodNotFoundError { + private final String method; + private final String message; + private final Object data; + + public MethodNotFoundError(String method, String message, Object data) { + this.method = method; + this.message = message; + this.data = data; + } + + public String getMethod() { + return method; + } + + public String getMessage() { + return message; + } + + public Object getData() { + return data; + } + } + + static MethodNotFoundError getMethodNotFoundError(String method) { + Map data = new HashMap<>(); + data.put("method", method); + return new MethodNotFoundError(method, "method not found: " + method, data); + } + + @Override + public CompletableFuture closeGracefully() { + return this.transport.closeGracefully(); + } + + @Override + public void close() { + this.transport.close(); + } + + public interface InitRequestHandler { + + CompletableFuture handle(McpSchema.InitializeRequest initializeRequest); + + } + + public interface InitNotificationHandler { + + CompletableFuture handle(); + + } + + public interface NotificationHandler { + + CompletableFuture handle(McpNettyServerExchange exchange, Object params); + + } + + public interface RequestHandler { + + CompletableFuture handle(McpNettyServerExchange exchange, Object params); + } + + @FunctionalInterface + public interface Factory { + + McpServerSession create(McpServerTransport sessionTransport); + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpServerTransport.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpServerTransport.java new file mode 100644 index 000000000..742b3eaf1 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpServerTransport.java @@ -0,0 +1,13 @@ +package com.taobao.arthas.mcp.server.protocol.spec; + +import io.netty.channel.Channel; + +/** + * Extends McpTransport to provide access to the underlying Netty Channel for server-side transport. + * + * @author Yeaury + */ +public interface McpServerTransport extends McpTransport { + + Channel getChannel(); +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpServerTransportProvider.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpServerTransportProvider.java new file mode 100644 index 000000000..342d9d840 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpServerTransportProvider.java @@ -0,0 +1,26 @@ +package com.taobao.arthas.mcp.server.protocol.spec; + +import com.taobao.arthas.mcp.server.protocol.server.handler.McpRequestHandler; + +import java.util.concurrent.CompletableFuture; + +/** + * Provides the abstraction for server-side transport providers in MCP protocol. + * Defines methods for session factory setup, client notification, and graceful shutdown. + * + * @author Yeaury + */ +public interface McpServerTransportProvider { + + void setSessionFactory(McpServerSession.Factory sessionFactory); + + CompletableFuture notifyClients(String method, Object params); + + CompletableFuture closeGracefully(); + + default void close() { + closeGracefully(); + } + + McpRequestHandler getMcpRequestHandler(); +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpSession.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpSession.java new file mode 100644 index 000000000..14b682f42 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpSession.java @@ -0,0 +1,26 @@ +package com.taobao.arthas.mcp.server.protocol.spec; + +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.core.type.TypeReference; + +/** + * Represents a server-side MCP session that manages bidirectional JSON-RPC communication with the client. + * + * @author Yeaury + */ +public interface McpSession { + + CompletableFuture sendRequest(String method, Object requestParams, TypeReference typeRef); + + default CompletableFuture sendNotification(String method) { + return sendNotification(method, null); + } + + CompletableFuture sendNotification(String method, Object params); + + CompletableFuture closeGracefully(); + + void close(); + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpTransport.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpTransport.java new file mode 100644 index 000000000..ffe8b1820 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/protocol/spec/McpTransport.java @@ -0,0 +1,24 @@ +package com.taobao.arthas.mcp.server.protocol.spec; + +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.core.type.TypeReference; + +/** + * Defines the transport abstraction for sending messages and unmarshalling data in MCP protocol. + * + * @author Yeaury + */ +public interface McpTransport { + + CompletableFuture closeGracefully(); + + default void close() { + this.closeGracefully(); + } + + CompletableFuture sendMessage(McpSchema.JSONRPCMessage message); + + T unmarshalFrom(Object data, TypeReference typeRef); + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/DefaultToolCallback.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/DefaultToolCallback.java new file mode 100644 index 000000000..33f3bb7fb --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/DefaultToolCallback.java @@ -0,0 +1,190 @@ +package com.taobao.arthas.mcp.server.tool; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.taobao.arthas.mcp.server.tool.definition.ToolDefinition; +import com.taobao.arthas.mcp.server.tool.execution.ToolCallResultConverter; +import com.taobao.arthas.mcp.server.tool.execution.ToolExecutionException; +import com.taobao.arthas.mcp.server.util.Assert; +import com.taobao.arthas.mcp.server.util.JsonParser; +import com.taobao.arthas.mcp.server.util.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Stream; + +public class DefaultToolCallback implements ToolCallback { + + private static final Logger logger = LoggerFactory.getLogger(DefaultToolCallback.class); + + private final ToolDefinition toolDefinition; + + private final Method toolMethod; + + private final Object toolObject; + + private final ToolCallResultConverter toolCallResultConverter; + + + public DefaultToolCallback(ToolDefinition toolDefinition, Method toolMethod, + Object toolObject, ToolCallResultConverter toolCallResultConverter) { + Assert.notNull(toolDefinition, "toolDefinition cannot be null"); + Assert.notNull(toolMethod, "toolMethod cannot be null"); + Assert.isTrue(Modifier.isStatic(toolMethod.getModifiers()) || toolObject != null, + "toolObject cannot be null for non-static methods"); + this.toolDefinition = toolDefinition; + this.toolMethod = toolMethod; + this.toolObject = toolObject; + this.toolCallResultConverter = toolCallResultConverter; + } + + @Override + public ToolDefinition getToolDefinition() { + return this.toolDefinition; + } + + @Override + public String call(String toolInput) { + return call(toolInput, null); + } + + @Override + public String call(String toolInput, ToolContext toolContext) { + Assert.hasText(toolInput, "toolInput cannot be null or empty"); + + logger.debug("Starting execution of tool: {}", this.toolDefinition.getName()); + + validateToolContextSupport(toolContext); + + Map toolArguments = extractToolArguments(toolInput); + + Object[] methodArguments = buildMethodArguments(toolArguments, toolContext); + + Object result = callMethod(methodArguments); + + logger.debug("Successful execution of tool: {}", this.toolDefinition.getName()); + + Type returnType = this.toolMethod.getGenericReturnType(); + + return this.toolCallResultConverter.convert(result, returnType); + } + + private void validateToolContextSupport(ToolContext toolContext) { + boolean isNonEmptyToolContextProvided = toolContext != null && !Utils.isEmpty(toolContext.getContext()); + + boolean isToolContextAcceptedByMethod = Arrays.stream(this.toolMethod.getParameterTypes()) + .anyMatch(type -> Utils.isAssignable(type, ToolContext.class)); + + if (isToolContextAcceptedByMethod && !isNonEmptyToolContextProvided) { + throw new IllegalArgumentException("ToolContext is required by the method as an argument"); + } + } + + private Map extractToolArguments(String toolInput) { + return JsonParser.fromJson(toolInput, new TypeReference>() { + }); + } + + private Object[] buildMethodArguments(Map toolInputArguments, ToolContext toolContext) { + return Stream.of(this.toolMethod.getParameters()).map(parameter -> { + if (parameter.getType().isAssignableFrom(ToolContext.class)) { + return toolContext; + } + Object rawArgument = toolInputArguments.get(parameter.getName()); + return buildTypedArgument(rawArgument, parameter.getParameterizedType()); + }).toArray(); + } + + private Object buildTypedArgument(Object value, Type type) { + if (value == null) { + return null; + } + + if (type instanceof Class) { + return JsonParser.toTypedObject(value, (Class) type); + } + + String json = JsonParser.toJson(value); + return JsonParser.fromJson(json, type); + } + + private Object callMethod(Object[] methodArguments) { + if (isObjectNotPublic() || isMethodNotPublic()) { + this.toolMethod.setAccessible(true); + } + Object result; + try { + result = this.toolMethod.invoke(this.toolObject, methodArguments); + } + catch (IllegalAccessException ex) { + throw new IllegalStateException("Could not access method: " + ex.getMessage(), ex); + } + catch (InvocationTargetException ex) { + throw new ToolExecutionException(this.toolDefinition, ex.getCause()); + } + return result; + } + + private boolean isObjectNotPublic() { + return this.toolObject != null && !Modifier.isPublic(this.toolObject.getClass().getModifiers()); + } + + private boolean isMethodNotPublic() { + return !Modifier.isPublic(this.toolMethod.getModifiers()); + } + + @Override + public String toString() { + return "MethodToolCallback{" + "toolDefinition=" + this.toolDefinition + '}'; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private ToolDefinition toolDefinition; + + private Method toolMethod; + + private Object toolObject; + + private ToolCallResultConverter toolCallResultConverter; + + private Builder() { + } + + public Builder toolDefinition(ToolDefinition toolDefinition) { + this.toolDefinition = toolDefinition; + return this; + } + + + public Builder toolMethod(Method toolMethod) { + this.toolMethod = toolMethod; + return this; + } + + public Builder toolObject(Object toolObject) { + this.toolObject = toolObject; + return this; + } + + public Builder toolCallResultConverter(ToolCallResultConverter toolCallResultConverter) { + this.toolCallResultConverter = toolCallResultConverter; + return this; + } + + + public DefaultToolCallback build() { + return new DefaultToolCallback(this.toolDefinition, this.toolMethod, this.toolObject, this.toolCallResultConverter); + } + + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/DefaultToolCallbackProvider.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/DefaultToolCallbackProvider.java new file mode 100644 index 000000000..b2247974e --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/DefaultToolCallbackProvider.java @@ -0,0 +1,170 @@ +package com.taobao.arthas.mcp.server.tool; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.definition.ToolDefinition; +import com.taobao.arthas.mcp.server.tool.definition.ToolDefinitions; +import com.taobao.arthas.mcp.server.tool.execution.DefaultToolCallResultConverter; +import com.taobao.arthas.mcp.server.tool.execution.ToolCallResultConverter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Default tool callback provider implementation + *

+ * Scan methods with @Tool annotations in the classpath and register them as tool callbacks + */ +public class DefaultToolCallbackProvider implements ToolCallbackProvider { + private static final Logger logger = LoggerFactory.getLogger(DefaultToolCallbackProvider.class); + private static final String DEFAULT_TOOL_BASE_PACKAGE = "com.taobao.arthas.mcp.server.tool.function"; + + private final ToolCallResultConverter toolCallResultConverter; + private ToolCallback[] toolCallbacks; + private String toolBasePackage; + + public DefaultToolCallbackProvider() { + this.toolCallResultConverter = new DefaultToolCallResultConverter(); + this.toolBasePackage = DEFAULT_TOOL_BASE_PACKAGE; + } + + public void setToolBasePackage(String toolBasePackage) { + this.toolBasePackage = toolBasePackage; + } + + @Override + public ToolCallback[] getToolCallbacks() { + if (toolCallbacks == null) { + synchronized (this) { + if (toolCallbacks == null) { + toolCallbacks = scanForToolCallbacks(); + } + } + } + return toolCallbacks; + } + + private ToolCallback[] scanForToolCallbacks() { + List callbacks = new ArrayList<>(); + try { + logger.info("Starting to scan for tool callbacks in package: {}", toolBasePackage); + scanPackageForToolMethods(toolBasePackage, callbacks); + logger.info("Found {} tool callbacks", callbacks.size()); + } catch (Exception e) { + logger.error("Failed to scan for tool callbacks: {}", e.getMessage(), e); + } + return callbacks.toArray(new ToolCallback[0]); + } + + private void scanPackageForToolMethods(String packageName, List callbacks) throws IOException { + String packageDirName = packageName.replace('.', '/'); + ClassLoader classLoader = DefaultToolCallbackProvider.class.getClassLoader(); + logger.info("Using classloader: {} for scanning package: {}", classLoader, packageName); + + Enumeration resources = classLoader.getResources(packageDirName); + if (!resources.hasMoreElements()) { + logger.warn("No resources found for package: {}", packageName); + return; + } + + while (resources.hasMoreElements()) { + URL resource = resources.nextElement(); + String protocol = resource.getProtocol(); + logger.info("Found resource: {} with protocol: {}", resource, protocol); + + if ("file".equals(protocol)) { + String filePath = URLDecoder.decode(resource.getFile(), StandardCharsets.UTF_8.name()); + logger.info("Scanning directory: {}", filePath); + scanDirectory(new File(filePath), packageName, callbacks); + } else if ("jar".equals(protocol)) { + JarURLConnection jarConn = (JarURLConnection) resource.openConnection(); + try (JarFile jarFile = jarConn.getJarFile()) { + logger.info("Scanning jar file: {}", jarFile.getName()); + scanJarEntries(jarFile, packageDirName, callbacks); + } + } else { + logger.warn("Unsupported protocol: {} for resource: {}", protocol, resource); + } + } + } + + private void scanDirectory(File directory, String packageName, List callbacks) { + if (!directory.exists() || !directory.isDirectory()) { + logger.warn("Directory does not exist or is not a directory: {}", directory); + return; + } + File[] files = directory.listFiles(); + if (files == null) { + logger.warn("Failed to list files in directory: {}", directory); + return; + } + for (File file : files) { + if (file.isDirectory()) { + scanDirectory(file, packageName + "." + file.getName(), callbacks); + } else if (file.getName().endsWith(".class")) { + String className = packageName + "." + file.getName().substring(0, file.getName().length() - 6); + logger.debug("Processing class: {}", className); + processClass(className, callbacks); + } + } + } + + private void scanJarEntries(JarFile jarFile, String packageDirName, List callbacks) { + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String name = entry.getName(); + if (name.startsWith(packageDirName) && name.endsWith(".class")) { + String className = name.substring(0, name.length() - 6).replace('/', '.'); + logger.debug("Processing jar entry: {}", className); + processClass(className, callbacks); + } + } + } + + private void processClass(String className, List callbacks) { + try { + Class clazz = Class.forName(className, false, DefaultToolCallbackProvider.class.getClassLoader()); + if (clazz.isInterface() || clazz.isEnum() || clazz.isAnnotation()) { + return; + } + for (Method method : clazz.getDeclaredMethods()) { + if (method.isAnnotationPresent(Tool.class)) { + registerToolMethod(clazz, method, callbacks); + } + } + } catch (Throwable t) { + logger.warn("Error loading class {}: {}", className, t.getMessage(), t); + } + } + + private void registerToolMethod(Class clazz, Method method, List callbacks) { + try { + ToolDefinition toolDefinition = ToolDefinitions.from(method); + Object toolObject = Modifier.isStatic(method.getModifiers()) ? null : clazz.getDeclaredConstructor().newInstance(); + ToolCallback callback = DefaultToolCallback.builder() + .toolDefinition(toolDefinition) + .toolMethod(method) + .toolObject(toolObject) + .toolCallResultConverter(toolCallResultConverter) + .build(); + callbacks.add(callback); + logger.info("Registered tool: {} from class: {}", toolDefinition.getName(), clazz.getName()); + } catch (Exception e) { + logger.error("Failed to register tool {}.{}, error: {}", + clazz.getName(), method.getName(), e.getMessage(), e); + } + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/ToolCallback.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/ToolCallback.java new file mode 100644 index 000000000..b3c8bea4e --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/ToolCallback.java @@ -0,0 +1,15 @@ +package com.taobao.arthas.mcp.server.tool; + +import com.taobao.arthas.mcp.server.tool.definition.ToolDefinition; + +/** + * Define the basic behavior of the tool + */ +public interface ToolCallback { + + ToolDefinition getToolDefinition(); + + String call(String toolInput); + + String call(String toolInput, ToolContext toolContext); +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/ToolCallbackProvider.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/ToolCallbackProvider.java new file mode 100644 index 000000000..ec78f5dd3 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/ToolCallbackProvider.java @@ -0,0 +1,7 @@ +package com.taobao.arthas.mcp.server.tool; + +public interface ToolCallbackProvider { + + ToolCallback[] getToolCallbacks(); + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/ToolContext.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/ToolContext.java new file mode 100644 index 000000000..c2746694a --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/ToolContext.java @@ -0,0 +1,18 @@ +package com.taobao.arthas.mcp.server.tool; + +import java.util.Collections; +import java.util.Map; + +public final class ToolContext { + + private final Map context; + + public ToolContext(Map context) { + this.context = Collections.unmodifiableMap(context); + } + + public Map getContext() { + return this.context; + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/annotation/Tool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/annotation/Tool.java new file mode 100644 index 000000000..4f1eb1bfe --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/annotation/Tool.java @@ -0,0 +1,16 @@ +package com.taobao.arthas.mcp.server.tool.annotation; + +import java.lang.annotation.*; + +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Tool { + + String name() default ""; + + String description() default ""; + + boolean streamable() default false; + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/annotation/ToolParam.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/annotation/ToolParam.java new file mode 100644 index 000000000..edeea65fc --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/annotation/ToolParam.java @@ -0,0 +1,20 @@ +package com.taobao.arthas.mcp.server.tool.annotation; + +import java.lang.annotation.*; + +@Target({ ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ToolParam { + + /** + * Whether the tool argument is required. + */ + boolean required() default true; + + /** + * The description of the tool argument. + */ + String description() default ""; + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/definition/ToolDefinition.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/definition/ToolDefinition.java new file mode 100644 index 000000000..2788b99b3 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/definition/ToolDefinition.java @@ -0,0 +1,76 @@ +package com.taobao.arthas.mcp.server.tool.definition; + +import com.taobao.arthas.mcp.server.protocol.spec.McpSchema; + +public class ToolDefinition { + private String name; + + private String description; + + private McpSchema.JsonSchema inputSchema; + + private boolean streamable; + + public ToolDefinition(String name, String description, + McpSchema.JsonSchema inputSchema, boolean streamable) { + this.name = name; + this.description = description; + this.inputSchema = inputSchema; + this.streamable = streamable; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public McpSchema.JsonSchema getInputSchema() { + return inputSchema; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String name; + + private String description; + + private McpSchema.JsonSchema inputSchema; + + private boolean streamable; + + private Builder() { + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder inputSchema(McpSchema.JsonSchema inputSchema) { + this.inputSchema = inputSchema; + return this; + } + + public Builder streamable(boolean streamable) { + this.streamable = streamable; + return this; + } + + public ToolDefinition build() { + return new ToolDefinition(this.name, this.description, this.inputSchema, this.streamable); + } + + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/definition/ToolDefinitions.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/definition/ToolDefinitions.java new file mode 100644 index 000000000..fd4b421a2 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/definition/ToolDefinitions.java @@ -0,0 +1,51 @@ +package com.taobao.arthas.mcp.server.tool.definition; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.util.JsonSchemaGenerator; +import com.taobao.arthas.mcp.server.util.Assert; + +import java.lang.reflect.Method; + +public class ToolDefinitions { + + public static ToolDefinition.Builder builder(Method method) { + Assert.notNull(method, "method cannot be null"); + return ToolDefinition.builder() + .name(getToolName(method)) + .description(getToolDescription(method)) + .inputSchema(JsonSchemaGenerator.generateForMethodInput(method)) + .streamable(isStreamable(method)); + } + + public static ToolDefinition from(Method method) { + return builder(method).build(); + } + + public static String getToolName(Method method) { + Assert.notNull(method, "method cannot be null"); + Tool tool = method.getAnnotation(Tool.class); + if (tool == null) { + return method.getName(); + } + return tool.name() != null ? tool.name() : method.getName(); + } + + public static String getToolDescription(Method method) { + Assert.notNull(method, "method cannot be null"); + Tool tool = method.getAnnotation(Tool.class); + if (tool == null) { + return method.getName(); + } + return tool.description() != null ? tool.description() : method.getName(); + } + + public static boolean isStreamable(Method method) { + Assert.notNull(method, "method cannot be null"); + Tool tool = method.getAnnotation(Tool.class); + if (tool == null) { + return false; + } + return tool.streamable(); + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/DefaultToolCallResultConverter.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/DefaultToolCallResultConverter.java new file mode 100644 index 000000000..bd9c152df --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/DefaultToolCallResultConverter.java @@ -0,0 +1,76 @@ +package com.taobao.arthas.mcp.server.tool.execution; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.taobao.arthas.mcp.server.util.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.image.RenderedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * A default implementation of {@link ToolCallResultConverter}. + */ +public final class DefaultToolCallResultConverter implements ToolCallResultConverter { + + private static final Logger logger = LoggerFactory.getLogger(DefaultToolCallResultConverter.class); + private static final ObjectMapper OBJECT_MAPPER = JsonParser.getObjectMapper(); + + @Override + public String convert(Object result, Type returnType) { + if (returnType == Void.TYPE) { + logger.debug("The tool has no return type. Converting to conventional response."); + return JsonParser.toJson("Done"); + } + if (result instanceof RenderedImage) { + final ByteArrayOutputStream buf = new ByteArrayOutputStream(1024 * 4); + try { + ImageIO.write((RenderedImage) result, "PNG", buf); + } + catch (IOException e) { + return "Failed to convert tool result to a base64 image: " + e.getMessage(); + } + final String imgB64 = Base64.getEncoder().encodeToString(buf.toByteArray()); + + Map imageData = new HashMap<>(); + imageData.put("mimeType", "image/png"); + imageData.put("data", imgB64); + + return JsonParser.toJson(imageData); + } + else if (result instanceof String) { + String stringResult = (String) result; + if (isValidJson(stringResult)) { + logger.debug("Result is already valid JSON, returning as is."); + return stringResult; + } else { + logger.debug("Converting string result to JSON."); + return JsonParser.toJson(result); + } + } + else { + logger.debug("Converting tool result to JSON."); + return JsonParser.toJson(result); + } + } + + private boolean isValidJson(String jsonString) { + if (jsonString == null || jsonString.trim().isEmpty()) { + return false; + } + try { + OBJECT_MAPPER.readTree(jsonString); + return true; + } catch (JsonProcessingException e) { + return false; + } + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/DefaultToolExecutionExceptionProcessor.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/DefaultToolExecutionExceptionProcessor.java new file mode 100644 index 000000000..4d1c2673e --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/DefaultToolExecutionExceptionProcessor.java @@ -0,0 +1,52 @@ +package com.taobao.arthas.mcp.server.tool.execution; + +import com.taobao.arthas.mcp.server.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default implementation of {@link ToolExecutionExceptionProcessor}. + */ +public class DefaultToolExecutionExceptionProcessor implements ToolExecutionExceptionProcessor { + + private final static Logger logger = LoggerFactory.getLogger(DefaultToolExecutionExceptionProcessor.class); + + private static final boolean DEFAULT_ALWAYS_THROW = false; + + private final boolean alwaysThrow; + + public DefaultToolExecutionExceptionProcessor(boolean alwaysThrow) { + this.alwaysThrow = alwaysThrow; + } + + @Override + public String process(ToolExecutionException exception) { + Assert.notNull(exception, "exception cannot be null"); + if (this.alwaysThrow) { + throw exception; + } + logger.debug("Exception thrown by tool: {}. Message: {}", exception.getToolDefinition().getName(), + exception.getMessage()); + return exception.getMessage(); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private boolean alwaysThrow = DEFAULT_ALWAYS_THROW; + + public Builder alwaysThrow(boolean alwaysThrow) { + this.alwaysThrow = alwaysThrow; + return this; + } + + public DefaultToolExecutionExceptionProcessor build() { + return new DefaultToolExecutionExceptionProcessor(this.alwaysThrow); + } + + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/ToolCallResultConverter.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/ToolCallResultConverter.java new file mode 100644 index 000000000..71e5c43c5 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/ToolCallResultConverter.java @@ -0,0 +1,18 @@ +package com.taobao.arthas.mcp.server.tool.execution; + +import java.lang.reflect.Type; + +/** + * A functional interface to convert tool call results to a String that can be sent back + * to the AI model. + */ +@FunctionalInterface +public interface ToolCallResultConverter { + + /** + * Given an Object returned by a tool, convert it to a String compatible with the + * given class type. + */ + String convert(Object result, Type returnType); + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/ToolExecutionException.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/ToolExecutionException.java new file mode 100644 index 000000000..2e14d1b4e --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/ToolExecutionException.java @@ -0,0 +1,21 @@ +package com.taobao.arthas.mcp.server.tool.execution; + +import com.taobao.arthas.mcp.server.tool.definition.ToolDefinition; + +/** + * An exception thrown when a tool execution fails. + */ +public class ToolExecutionException extends RuntimeException { + + private final ToolDefinition toolDefinition; + + public ToolExecutionException(ToolDefinition toolDefinition, Throwable cause) { + super(cause.getMessage(), cause); + this.toolDefinition = toolDefinition; + } + + public ToolDefinition getToolDefinition() { + return this.toolDefinition; + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/ToolExecutionExceptionProcessor.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/ToolExecutionExceptionProcessor.java new file mode 100644 index 000000000..eaf540d31 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/execution/ToolExecutionExceptionProcessor.java @@ -0,0 +1,17 @@ +package com.taobao.arthas.mcp.server.tool.execution; + +/** + * A functional interface to process a {@link ToolExecutionException} by either converting + * the error message to a String that can be sent back to the AI model or throwing an + * exception to be handled by the caller. + */ +@FunctionalInterface +public interface ToolExecutionExceptionProcessor { + + /** + * Convert an exception thrown by a tool to a String that can be sent back to the AI + * model or throw an exception to be handled by the caller. + */ + String process(ToolExecutionException exception); + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/ArthasCommandExecutor.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/ArthasCommandExecutor.java new file mode 100644 index 000000000..2cb3bf930 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/ArthasCommandExecutor.java @@ -0,0 +1,54 @@ +package com.taobao.arthas.mcp.server.tool.function; + +import com.taobao.arthas.mcp.server.ArthasMcpBootstrap; +import com.taobao.arthas.mcp.server.CommandExecutor; +import com.taobao.arthas.mcp.server.util.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +public class ArthasCommandExecutor { + private static final Logger logger = LoggerFactory.getLogger(ArthasCommandExecutor.class); + private static final long DEFAULT_TIMEOUT = 15000; // 默认超时时间15秒 + + /** + * Execute Arthas commands + * @param command command string + * @return Execution result as JSON string + */ + public static String executeCommand(String command) { + return executeCommand(command, DEFAULT_TIMEOUT); + } + + /** + * Execute Arthas commands + * @param command command string + * @param timeout timeout (ms) + * @return Execution result as JSON string + */ + public static String executeCommand(String command, long timeout) { + try { + ArthasMcpBootstrap bootstrap = ArthasMcpBootstrap.getInstance(); + if (bootstrap == null) { + throw new IllegalStateException("ArthasMcpBootstrap not initialized"); + } + + CommandExecutor executor = bootstrap.getCommandExecutor(); + if (executor == null) { + throw new IllegalStateException("CommandExecutor not initialized"); + } + + Map result = executor.execute(command, timeout); + return JsonParser.toJson(result); + } catch (Exception e) { + logger.error("Failed to execute command: {}", command, e); + Map errorResponse = new HashMap<>(); + errorResponse.put("error", true); + errorResponse.put("message", "Error executing command '" + command + "': " + e.getMessage()); + return JsonParser.toJson(errorResponse); + } + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/GetStaticTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/GetStaticTool.java new file mode 100644 index 000000000..9432df9a0 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/GetStaticTool.java @@ -0,0 +1,48 @@ +package com.taobao.arthas.mcp.server.tool.function.jvm300; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class GetStaticTool { + + + @Tool( + name = "getstatic", + description = "GetStatic 诊断工具: 查看类的静态字段值,可指定 ClassLoader,支持在返回结果上执行 OGNL 表达式。对应 Arthas 的 getstatic 命令。" + ) + public String getstatic( + @ToolParam(description = "ClassLoader的hashcode(16进制),用于指定特定的ClassLoader", required = false) + String classLoaderHash, + + @ToolParam(description = "ClassLoader的完整类名,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false) + String classLoaderClass, + + @ToolParam(description = "类名表达式匹配,如java.lang.String或demo.MathGame") + String className, + + @ToolParam(description = "静态字段名") + String fieldName, + + @ToolParam(description = "OGNL 表达式", required = false) + String ognlExpression + ) { + StringBuilder cmd = new StringBuilder("getstatic"); + + if (classLoaderHash != null && !classLoaderHash.trim().isEmpty()) { + cmd.append(" -c ").append(classLoaderHash.trim()); + } + if (classLoaderClass != null && !classLoaderClass.trim().isEmpty()) { + cmd.append(" --classLoaderClass ").append(classLoaderClass.trim()); + } + cmd.append(" ").append(className.trim()); + cmd.append(" ").append(fieldName.trim()); + + if (ognlExpression != null && !ognlExpression.trim().isEmpty()) { + cmd.append(" ").append(ognlExpression.trim()); + } + + String commandStr = cmd.toString(); + return ArthasCommandExecutor.executeCommand(commandStr); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/JvmTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/JvmTool.java new file mode 100644 index 000000000..4e3516cd8 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/JvmTool.java @@ -0,0 +1,16 @@ +package com.taobao.arthas.mcp.server.tool.function.jvm300; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class JvmTool { + + @Tool( + name = "jvm", + description = "Jvm 诊断工具: 查看当前 JVM 运行时信息。对应 Arthas 的 jvm 命令。" + ) + public String jvm() { + String commandStr = "jvm"; + return ArthasCommandExecutor.executeCommand(commandStr); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/MemoryTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/MemoryTool.java new file mode 100644 index 000000000..755c4b365 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/MemoryTool.java @@ -0,0 +1,16 @@ +package com.taobao.arthas.mcp.server.tool.function.jvm300; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class MemoryTool { + + @Tool( + name = "memory", + description = "Memory 诊断工具: 查看 JVM 内存使用情况,对应 Arthas 的 memory 命令。" + ) + public String memory() { + String commandStr = "memory"; + return ArthasCommandExecutor.executeCommand(commandStr); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/OgnlTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/OgnlTool.java new file mode 100644 index 000000000..551c53951 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/OgnlTool.java @@ -0,0 +1,42 @@ +package com.taobao.arthas.mcp.server.tool.function.jvm300; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class OgnlTool { + + @Tool( + name = "ognl", + description = "OGNL 诊断工具: 执行 OGNL 表达式,对应 Arthas 的 ognl 命令。" + ) + public String ognl( + @ToolParam(description = "OGNL 表达式") + String expression, + + @ToolParam(description = "ClassLoader的hashcode(16进制),用于指定特定的ClassLoader", required = false) + String classLoaderHash, + + @ToolParam(description = "ClassLoader的完整类名,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false) + String classLoaderClass, + + @ToolParam(description = "结果对象展开层次 (-x),默认 1", required = false) + Integer expandLevel + ) { + StringBuilder cmd = new StringBuilder("ognl"); + + if (classLoaderHash != null && !classLoaderHash.trim().isEmpty()) { + cmd.append(" -c ").append(classLoaderHash.trim()); + } + if (classLoaderClass != null && !classLoaderClass.trim().isEmpty()) { + cmd.append(" --classLoaderClass ").append(classLoaderClass.trim()); + } + if (expandLevel != null && expandLevel > 0) { + cmd.append(" -x ").append(expandLevel); + } + cmd.append(" ").append(expression.trim()); + + String commandStr = cmd.toString(); + return ArthasCommandExecutor.executeCommand(commandStr); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/PerfCounterTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/PerfCounterTool.java new file mode 100644 index 000000000..2f61c0b49 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/PerfCounterTool.java @@ -0,0 +1,24 @@ +package com.taobao.arthas.mcp.server.tool.function.jvm300; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class PerfCounterTool { + + @Tool( + name = "perfcounter", + description = "PerfCounter 诊断工具: 查看 JVM Perf Counter 信息,对应 Arthas 的 perfcounter 命令。" + ) + public String perfcounter( + @ToolParam(description = "是否打印更多详情 (-d)", required = false) + Boolean detailed + ) { + StringBuilder cmd = new StringBuilder("perfcounter"); + if (Boolean.TRUE.equals(detailed)) { + cmd.append(" -d"); + } + String commandStr = cmd.toString(); + return ArthasCommandExecutor.executeCommand(commandStr); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/SysEnvTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/SysEnvTool.java new file mode 100644 index 000000000..aa2fac2c5 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/SysEnvTool.java @@ -0,0 +1,24 @@ +package com.taobao.arthas.mcp.server.tool.function.jvm300; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class SysEnvTool { + + @Tool( + name = "sysenv", + description = "SysEnv 诊断工具: 查看系统环境变量,对应 Arthas 的 sysenv 命令。" + ) + public String sysenv( + @ToolParam(description = "环境变量名。若为空或空字符串,则查看所有变量;否则查看单个变量值。", required = false) + String envName + ) { + StringBuilder cmd = new StringBuilder("sysenv"); + if (envName != null && !envName.trim().isEmpty()) { + cmd.append(" ").append(envName.trim()); + } + String commandStr = cmd.toString(); + return ArthasCommandExecutor.executeCommand(commandStr); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/SysPropTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/SysPropTool.java new file mode 100644 index 000000000..52e170552 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/SysPropTool.java @@ -0,0 +1,30 @@ +package com.taobao.arthas.mcp.server.tool.function.jvm300; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class SysPropTool { + + @Tool( + name = "sysprop", + description = "SysProp 诊断工具: 查看或修改系统属性,对应 Arthas 的 sysprop 命令。" + ) + public String sysprop( + @ToolParam(description = "属性名", required = false) + String propertyName, + + @ToolParam(description = "属性值;若指定则修改,否则查看", required = false) + String propertyValue + ) { + StringBuilder cmd = new StringBuilder("sysprop"); + if (propertyName != null && !propertyName.trim().isEmpty()) { + cmd.append(" ").append(propertyName.trim()); + if (propertyValue != null && !propertyValue.trim().isEmpty()) { + cmd.append(" ").append(propertyValue.trim()); + } + } + String commandStr = cmd.toString(); + return ArthasCommandExecutor.executeCommand(commandStr); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/VMOptionTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/VMOptionTool.java new file mode 100644 index 000000000..fce7cd99c --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/VMOptionTool.java @@ -0,0 +1,30 @@ +package com.taobao.arthas.mcp.server.tool.function.jvm300; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class VMOptionTool { + + @Tool( + name = "vmoption", + description = "VMOption 诊断工具: 查看或更新 JVM VM options,对应 Arthas 的 vmoption 命令。" + ) + public String vmoption( + @ToolParam(description = "Name of the VM option.", required = false) + String key, + + @ToolParam(description = "更新值,仅在更新时使用", required = false) + String value + ) { + StringBuilder cmd = new StringBuilder("vmoption"); + if (key != null && !key.trim().isEmpty()) { + cmd.append(" ").append(key.trim()); + if (value != null && !value.trim().isEmpty()) { + cmd.append(" ").append(value.trim()); + } + } + String commandStr = cmd.toString(); + return ArthasCommandExecutor.executeCommand(commandStr); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/VMToolTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/VMToolTool.java new file mode 100644 index 000000000..6b6c6a985 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/jvm300/VMToolTool.java @@ -0,0 +1,83 @@ +package com.taobao.arthas.mcp.server.tool.function.jvm300; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class VMToolTool { + + public static final String ACTION_GET_INSTANCES = "getInstances"; + public static final String ACTION_FORCE_GC = "forceGc"; + public static final String ACTION_INTERRUPT_THREAD = "interruptThread"; + + @Tool( + name = "vmtool", + description = "虚拟机工具诊断工具: 查询实例、强制 GC、线程中断等,对应 Arthas 的 vmtool 命令。" + ) + public String vmtool( + @ToolParam(description = "操作类型: getInstances/forceGc/interruptThread 等") + String action, + + @ToolParam(description = "ClassLoader的hashcode(16进制),用于指定特定的ClassLoader", required = false) + String classLoaderHash, + + @ToolParam(description = "ClassLoader的完整类名,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false) + String classLoaderClass, + + @ToolParam(description = "类名,全限定(getInstances 时使用)", required = false) + String className, + + @ToolParam(description = "返回实例限制数量 (-l),getInstances 时使用,默认 10;<=0 表示不限制", required = false) + Integer limit, + + @ToolParam(description = "结果对象展开层次 (-x),默认 1", required = false) + Integer expandLevel, + + @ToolParam(description = "OGNL 表达式,对 getInstances 返回的 instances 执行 (--express)", required = false) + String express, + + @ToolParam(description = "线程 ID (-t),interruptThread 时使用", required = false) + Long threadId + ) { + StringBuilder cmd = new StringBuilder("vmtool"); + if (action == null || action.trim().isEmpty()) { + throw new IllegalArgumentException("vmtool: action 参数不能为空"); + } + cmd.append(" --action ").append(action.trim()); + + if (classLoaderHash != null && !classLoaderHash.trim().isEmpty()) { + cmd.append(" -c ").append(classLoaderHash.trim()); + } + if (classLoaderClass != null && !classLoaderClass.trim().isEmpty()) { + cmd.append(" --classLoaderClass ").append(classLoaderClass.trim()); + } + if (ACTION_GET_INSTANCES.equals(action.trim())) { + if (className != null && !className.trim().isEmpty()) { + cmd.append(" --className ").append(className.trim()); + } + if (limit != null) { + cmd.append(" --limit ").append(limit); + } + if (expandLevel != null && expandLevel > 0) { + cmd.append(" -x ").append(expandLevel); + } + if (express != null && !express.trim().isEmpty()) { + cmd.append(" --express ").append(express.trim()); + } + } + + // interruptThread + if (ACTION_INTERRUPT_THREAD.equals(action.trim())) { + if (threadId != null && threadId > 0) { + cmd.append(" -t ").append(threadId); + } else { + throw new IllegalArgumentException("vmtool interruptThread 需要指定线程 ID (threadId)"); + } + } + + String commandStr = cmd.toString(); + return ArthasCommandExecutor.executeCommand(commandStr); + } + + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/ClassLoaderTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/ClassLoaderTool.java new file mode 100644 index 000000000..9c2180938 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/ClassLoaderTool.java @@ -0,0 +1,73 @@ +package com.taobao.arthas.mcp.server.tool.function.klass100; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class ClassLoaderTool { + + public static final String MODE_STATS = "stats"; + public static final String MODE_INSTANCES = "instances"; + public static final String MODE_TREE = "tree"; + public static final String MODE_ALL_CLASSES = "all-classes"; + public static final String MODE_URL_STATS = "url-stats"; + + @Tool( + name = "classloader", + description = "ClassLoader 诊断工具,可以查看类加载器统计信息、继承树、URLs,以及进行资源查找和类加载操作" + ) + public String classloader( + @ToolParam(description = "显示模式:stats(统计信息,默认), instances(实例详情), tree(继承树), all-classes(所有类,慎用), url-stats(URL统计)", required = false) + String mode, + + @ToolParam(description = "ClassLoader的hashcode(16进制),用于指定特定的ClassLoader", required = false) + String classLoaderHash, + + @ToolParam(description = "ClassLoader的完整类名,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false) + String classLoaderClass, + + @ToolParam(description = "要查找的资源名称,如META-INF/MANIFEST.MF", required = false) + String resource, + + @ToolParam(description = "要加载的类名,支持全限定名", required = false) + String loadClass) { + + StringBuilder cmd = new StringBuilder("classloader"); + + if (mode != null) { + switch (mode.toLowerCase()) { + case MODE_INSTANCES: + cmd.append(" -l"); + break; + case MODE_TREE: + cmd.append(" -t"); + break; + case MODE_ALL_CLASSES: + cmd.append(" -a"); + break; + case MODE_URL_STATS: + cmd.append(" --url-stat"); + break; + case MODE_STATS: + default: + break; + } + } + + if (classLoaderHash != null && !classLoaderHash.trim().isEmpty()) { + cmd.append(" -c ").append(classLoaderHash.trim()); + } else if (classLoaderClass != null && !classLoaderClass.trim().isEmpty()) { + cmd.append(" --classLoaderClass ").append(classLoaderClass.trim()); + } + + if (resource != null && !resource.trim().isEmpty()) { + cmd.append(" -r ").append(resource.trim()); + } + + if (loadClass != null && !loadClass.trim().isEmpty()) { + cmd.append(" --load ").append(loadClass.trim()); + } + + return ArthasCommandExecutor.executeCommand(cmd.toString()); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/JadTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/JadTool.java new file mode 100644 index 000000000..43eceaef9 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/JadTool.java @@ -0,0 +1,63 @@ +package com.taobao.arthas.mcp.server.tool.function.klass100; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class JadTool { + + @Tool( + name = "jad", + description = "反编译指定已加载类的源码,将JVM中实际运行的class的bytecode反编译成java代码" + ) + public String jad( + @ToolParam(description = "类名表达式匹配,如java.lang.String或demo.MathGame") + String classPattern, + + @ToolParam(description = "ClassLoader的hashcode(16进制),用于指定特定的ClassLoader", required = false) + String classLoaderHash, + + @ToolParam(description = "ClassLoader的完整类名,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false) + String classLoaderClass, + + @ToolParam(description = "反编译时只显示源代码,默认false", required = false) + Boolean sourceOnly, + + @ToolParam(description = "反编译时不显示行号,默认false", required = false) + Boolean noLineNumber, + + @ToolParam(description = "开启正则表达式匹配,默认为通配符匹配,默认false", required = false) + Boolean useRegex, + + @ToolParam(description = "指定dump class文件目录,默认会dump到logback.xml中配置的log目录下", required = false) + String dumpDirectory) { + + StringBuilder cmd = new StringBuilder("jad"); + + cmd.append(" ").append(classPattern); + + if (classLoaderHash != null && !classLoaderHash.trim().isEmpty()) { + cmd.append(" -c ").append(classLoaderHash.trim()); + } else if (classLoaderClass != null && !classLoaderClass.trim().isEmpty()) { + cmd.append(" --classLoaderClass ").append(classLoaderClass.trim()); + } + + if (Boolean.TRUE.equals(sourceOnly)) { + cmd.append(" --source-only"); + } + + if (Boolean.TRUE.equals(noLineNumber)) { + cmd.append(" --lineNumber false"); + } + + if (Boolean.TRUE.equals(useRegex)) { + cmd.append(" -E"); + } + + if (dumpDirectory != null && !dumpDirectory.trim().isEmpty()) { + cmd.append(" -d ").append(dumpDirectory.trim()); + } + + return ArthasCommandExecutor.executeCommand(cmd.toString()); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/MemoryCompilerTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/MemoryCompilerTool.java new file mode 100644 index 000000000..6a20620fd --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/MemoryCompilerTool.java @@ -0,0 +1,42 @@ +package com.taobao.arthas.mcp.server.tool.function.klass100; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class MemoryCompilerTool { + + @Tool( + name = "mc", + description = "Memory Compiler/内存编译器,编译.java文件生成.class" + ) + public String mc( + @ToolParam(description = "要编译的.java文件路径,支持多个文件,用空格分隔") + String javaFilePaths, + + @ToolParam(description = "ClassLoader的hashcode(16进制),用于指定特定的ClassLoader", required = false) + String classLoaderHash, + + @ToolParam(description = "ClassLoader的完整类名,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false) + String classLoaderClass, + + @ToolParam(description = "指定输出目录", required = false) + String outputDir) { + + StringBuilder cmd = new StringBuilder("mc"); + + cmd.append(" ").append(javaFilePaths); + + if (classLoaderHash != null && !classLoaderHash.trim().isEmpty()) { + cmd.append(" -c ").append(classLoaderHash.trim()); + } else if (classLoaderClass != null && !classLoaderClass.trim().isEmpty()) { + cmd.append(" --classLoaderClass ").append(classLoaderClass.trim()); + } + + if (outputDir != null && !outputDir.trim().isEmpty()) { + cmd.append(" -d ").append(outputDir.trim()); + } + + return ArthasCommandExecutor.executeCommand(cmd.toString()); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/RedefineTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/RedefineTool.java new file mode 100644 index 000000000..2190f8f2e --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/RedefineTool.java @@ -0,0 +1,35 @@ +package com.taobao.arthas.mcp.server.tool.function.klass100; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class RedefineTool { + + @Tool( + name = "redefine", + description = "重新加载类的字节码,允许在JVM运行时,重新加载已存在的类的字节码,实现热更新" + ) + public String redefine( + @ToolParam(description = "要重新定义的.class文件路径,支持多个文件,用空格分隔") + String classFilePaths, + + @ToolParam(description = "ClassLoader的hashcode(16进制),用于指定特定的ClassLoader", required = false) + String classLoaderHash, + + @ToolParam(description = "指定执行表达式的ClassLoader的class name,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false) + String classLoaderClass) { + + StringBuilder cmd = new StringBuilder("redefine"); + + cmd.append(" ").append(classFilePaths); + + if (classLoaderHash != null && !classLoaderHash.trim().isEmpty()) { + cmd.append(" -c ").append(classLoaderHash.trim()); + } else if (classLoaderClass != null && !classLoaderClass.trim().isEmpty()) { + cmd.append(" --classLoaderClass ").append(classLoaderClass.trim()); + } + + return ArthasCommandExecutor.executeCommand(cmd.toString()); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/RetransformTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/RetransformTool.java new file mode 100644 index 000000000..f279253e3 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/RetransformTool.java @@ -0,0 +1,35 @@ +package com.taobao.arthas.mcp.server.tool.function.klass100; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class RetransformTool { + + @Tool( + name = "retransform", + description = "热加载类的字节码,允许对已加载的类进行字节码修改并使其生效" + ) + public String retransform( + @ToolParam(description = "要操作的.class文件路径,支持多个文件,用空格分隔") + String classFilePaths, + + @ToolParam(description = "ClassLoader的hashcode(16进制),用于指定特定的ClassLoader", required = false) + String classLoaderHash, + + @ToolParam(description = "ClassLoader的完整类名,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false) + String classLoaderClass) { + + StringBuilder cmd = new StringBuilder("retransform"); + + cmd.append(" ").append(classFilePaths); + + if (classLoaderHash != null && !classLoaderHash.trim().isEmpty()) { + cmd.append(" -c ").append(classLoaderHash.trim()); + } else if (classLoaderClass != null && !classLoaderClass.trim().isEmpty()) { + cmd.append(" --classLoaderClass ").append(classLoaderClass.trim()); + } + + return ArthasCommandExecutor.executeCommand(cmd.toString()); + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/SearchClassTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/SearchClassTool.java new file mode 100644 index 000000000..e0337e0cb --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/SearchClassTool.java @@ -0,0 +1,19 @@ +package com.taobao.arthas.mcp.server.tool.function.klass100; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class SearchClassTool { + + @Tool( + name = "getClassInfo", + description = "获取指定类的详细信息,包括类加载器、方法、字段等信息" + ) + public String sc( + @ToolParam(description = "要查询的类名,支持全限定名") String className) { + return ArthasCommandExecutor.executeCommand("sc -d " + className); + } + + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/SearchMethodTool.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/SearchMethodTool.java new file mode 100644 index 000000000..8021790d6 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/function/klass100/SearchMethodTool.java @@ -0,0 +1,18 @@ +package com.taobao.arthas.mcp.server.tool.function.klass100; + +import com.taobao.arthas.mcp.server.tool.annotation.Tool; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor; + +public class SearchMethodTool { + + @Tool( + name = "getClassMethods", + description = "获取指定类的所有方法信息" + ) + public String sm( + @ToolParam(description = "要查询的类名,支持全限定名") String className) { + return ArthasCommandExecutor.executeCommand("sm " + className); + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/util/JsonSchemaGenerator.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/util/JsonSchemaGenerator.java new file mode 100644 index 000000000..38b87ee5c --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/util/JsonSchemaGenerator.java @@ -0,0 +1,174 @@ +package com.taobao.arthas.mcp.server.tool.util; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.taobao.arthas.mcp.server.protocol.spec.McpSchema; +import com.taobao.arthas.mcp.server.tool.annotation.ToolParam; +import com.taobao.arthas.mcp.server.util.Assert; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Simple JsonSchema generator + * JsonSchema definitions for generating method parameters + */ +public final class JsonSchemaGenerator { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final boolean PROPERTY_REQUIRED_BY_DEFAULT = true; + + private JsonSchemaGenerator() { + } + + /** + * Generate JsonSchema for method parameters + * @param method target method + * @return JsonSchema object + */ + public static McpSchema.JsonSchema generateForMethodInput(Method method) { + Assert.notNull(method, "method cannot be null"); + + ObjectNode schema = OBJECT_MAPPER.createObjectNode(); + schema.put("type", "object"); + + ObjectNode properties = schema.putObject("properties"); + List required = new ArrayList<>(); + + Parameter[] parameters = method.getParameters(); + for (Parameter parameter : parameters) { + String paramName = getParameterName(parameter); + Class paramType = parameter.getType(); + + boolean isRequired = isParameterRequired(parameter); + if (isRequired) { + required.add(paramName); + } + + ObjectNode paramProperties = generateParameterProperties(paramType); + + String description = getParameterDescription(parameter); + if (description != null) { + paramProperties.put("description", description); + } + + properties.set(paramName, paramProperties); + } + + if (!required.isEmpty()) { + ArrayNode requiredArray = schema.putArray("required"); + for (String req : required) { + requiredArray.add(req); + } + } + + schema.put("additionalProperties", false); + + return new McpSchema.JsonSchema("object", convertToMap(properties), required, false); + } + + private static String getParameterName(Parameter parameter) { + JsonProperty jsonProperty = parameter.getAnnotation(JsonProperty.class); + if (jsonProperty != null && !jsonProperty.value().isEmpty()) { + return jsonProperty.value(); + } + return parameter.getName(); + } + + private static ObjectNode generateParameterProperties(Class paramType) { + ObjectNode properties = OBJECT_MAPPER.createObjectNode(); + + if (paramType == String.class) { + properties.put("type", "string"); + } else if (paramType == int.class || paramType == Integer.class + || paramType == long.class || paramType == Long.class) { + properties.put("type", "integer"); + } else if (paramType == double.class || paramType == Double.class + || paramType == float.class || paramType == Float.class) { + properties.put("type", "number"); + } else if (paramType == boolean.class || paramType == Boolean.class) { + properties.put("type", "boolean"); + } else if (paramType.isArray()) { + properties.put("type", "array"); + ObjectNode items = properties.putObject("items"); + Class componentType = paramType.getComponentType(); + + if (componentType == String.class) { + items.put("type", "string"); + } else if (componentType == int.class || componentType == Integer.class + || componentType == long.class || componentType == Long.class) { + items.put("type", "integer"); + } else if (componentType == double.class || componentType == Double.class + || componentType == float.class || componentType == Float.class) { + items.put("type", "number"); + } else if (componentType == boolean.class || componentType == Boolean.class) { + items.put("type", "boolean"); + } else { + items.put("type", "object"); + } + } else { + properties.put("type", "object"); + } + + return properties; + } + + private static boolean isParameterRequired(Parameter parameter) { + ToolParam toolParam = parameter.getAnnotation(ToolParam.class); + if (toolParam != null) { + return toolParam.required(); + } + + JsonProperty jsonProperty = parameter.getAnnotation(JsonProperty.class); + if (jsonProperty != null) { + return jsonProperty.required(); + } + + return PROPERTY_REQUIRED_BY_DEFAULT; + } + + private static String getParameterDescription(Parameter parameter) { + ToolParam toolParam = parameter.getAnnotation(ToolParam.class); + if (toolParam != null && toolParam.description() != null && !toolParam.description().isEmpty()) { + return toolParam.description(); + } + + JsonPropertyDescription jsonPropertyDescription = parameter.getAnnotation(JsonPropertyDescription.class); + if (jsonPropertyDescription != null && !jsonPropertyDescription.value().isEmpty()) { + return jsonPropertyDescription.value(); + } + + return null; + } + + private static Map convertToMap(ObjectNode node) { + Map result = new HashMap<>(); + node.fields().forEachRemaining(entry -> { + JsonNode value = entry.getValue(); + if (value.isObject()) { + result.put(entry.getKey(), convertToMap((ObjectNode) value)); + } else if (value.isArray()) { + List array = new ArrayList<>(); + value.elements().forEachRemaining(element -> { + if (element.isObject()) { + array.add(convertToMap((ObjectNode) element)); + } else { + array.add(element.asText()); + } + }); + result.put(entry.getKey(), array); + } else { + result.put(entry.getKey(), value.asText()); + } + }); + return result; + } +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/util/McpToolUtils.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/util/McpToolUtils.java new file mode 100644 index 000000000..dccc0da31 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/tool/util/McpToolUtils.java @@ -0,0 +1,85 @@ +package com.taobao.arthas.mcp.server.tool.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.taobao.arthas.mcp.server.protocol.config.McpServerProperties; +import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange; +import com.taobao.arthas.mcp.server.protocol.server.McpServerFeatures; +import com.taobao.arthas.mcp.server.protocol.spec.McpSchema; +import com.taobao.arthas.mcp.server.tool.ToolCallback; +import com.taobao.arthas.mcp.server.tool.ToolContext; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; +import java.util.stream.Collectors; + +public final class McpToolUtils { + + public static final String TOOL_CONTEXT_MCP_EXCHANGE_KEY = "exchange"; + + private McpToolUtils() { + } + + public static List toToolSpecifications( + List tools, + McpServerProperties serverProperties) { + + // De-duplicate tools by their name, keeping the first occurrence of each tool name + return tools.stream() + .collect(Collectors.toMap( + tool -> tool.getToolDefinition().getName(), // Key: tool name + tool -> tool, // Value: the tool itself + (existing, replacement) -> existing // On duplicate key, keep the existing tool + )) + .values() + .stream() + .map(McpToolUtils::toToolSpecification) + .collect(Collectors.toList()); + } + + public static McpServerFeatures.ToolSpecification toToolSpecification(ToolCallback toolCallback) { + + McpSchema.Tool tool = new McpSchema.Tool(toolCallback.getToolDefinition().getName(), + toolCallback.getToolDefinition().getDescription(), toolCallback.getToolDefinition().getInputSchema()); + + return new McpServerFeatures.ToolSpecification(tool, new BiFunction, CompletableFuture>() { + + @Override + public CompletableFuture apply(McpNettyServerExchange exchange, Map request) { + try { + Map contextMap = new HashMap<>(); + contextMap.put(TOOL_CONTEXT_MCP_EXCHANGE_KEY, exchange); + ToolContext toolContext = new ToolContext(contextMap); + + String callResult = toolCallback.call(convertRequestToString(request), toolContext); + + List contents = new ArrayList<>(); + contents.add(new McpSchema.TextContent(callResult)); + + return CompletableFuture.completedFuture(new McpSchema.CallToolResult(contents, false)); + } + catch (Exception e) { + List contents = new ArrayList<>(); + contents.add(new McpSchema.TextContent(e.getMessage())); + + return CompletableFuture.completedFuture(new McpSchema.CallToolResult(contents, true)); + } + } + + private String convertRequestToString(Map request) { + if (request == null) { + return ""; + } + try { + return new ObjectMapper().writeValueAsString(request); + } catch (Exception e) { + return request.toString(); + } + } + }); + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/util/Assert.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/util/Assert.java new file mode 100644 index 000000000..ec4eefc0a --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/util/Assert.java @@ -0,0 +1,44 @@ +package com.taobao.arthas.mcp.server.util; + +import java.util.Collection; +import java.util.Map; + +/** + * Assertion utility class for parameter validation. + */ +public final class Assert { + + private Assert() { + } + + public static void notNull(Object object, String message) { + if (object == null) { + throw new IllegalArgumentException(message); + } + } + + public static void hasText(String text, String message) { + if (text == null || text.trim().isEmpty()) { + throw new IllegalArgumentException(message); + } + } + + public static void notEmpty(Collection collection, String message) { + if (collection == null || collection.isEmpty()) { + throw new IllegalArgumentException(message); + } + } + + public static void notEmpty(Map map, String message) { + if (map == null || map.isEmpty()) { + throw new IllegalArgumentException(message); + } + } + + public static void isTrue(boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException(message); + } + } + +} diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/util/JsonParser.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/util/JsonParser.java new file mode 100644 index 000000000..7794228ea --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/util/JsonParser.java @@ -0,0 +1,141 @@ +package com.taobao.arthas.mcp.server.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; + +import java.lang.reflect.Type; +import java.math.BigDecimal; + +/** + * Utilities to perform parsing operations between JSON and Java. + */ +public final class JsonParser { + + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .build(); + + private JsonParser() { + } + + public static ObjectMapper getObjectMapper() { + return OBJECT_MAPPER; + } + + public static T fromJson(String json, Class type) { + Assert.notNull(json, "json cannot be null"); + Assert.notNull(type, "type cannot be null"); + + try { + return OBJECT_MAPPER.readValue(json, type); + } + catch (JsonProcessingException ex) { + throw new IllegalStateException("Conversion from JSON to " + type.getName() + " failed", ex); + } + } + + public static T fromJson(String json, Type type) { + Assert.notNull(json, "json cannot be null"); + Assert.notNull(type, "type cannot be null"); + + try { + return OBJECT_MAPPER.readValue(json, OBJECT_MAPPER.constructType(type)); + } + catch (JsonProcessingException ex) { + throw new IllegalStateException("Conversion from JSON to " + type.getTypeName() + " failed", ex); + } + } + + public static T fromJson(String json, TypeReference type) { + Assert.notNull(json, "json cannot be null"); + Assert.notNull(type, "type cannot be null"); + + try { + return OBJECT_MAPPER.readValue(json, type); + } + catch (JsonProcessingException ex) { + throw new IllegalStateException("Conversion from JSON to " + type.getType().getTypeName() + " failed", + ex); + } + } + + /** + * Converts a Java object to a JSON string. + */ + public static String toJson(Object object) { + try { + return OBJECT_MAPPER.writeValueAsString(object); + } + catch (JsonProcessingException ex) { + throw new IllegalStateException("Conversion from Object to JSON failed", ex); + } + } + + public static Object toTypedObject(Object value, Class type) { + if (value == null) { + throw new IllegalArgumentException("value cannot be null"); + } + if (type == null) { + throw new IllegalArgumentException("type cannot be null"); + } + + Class javaType = resolvePrimitiveIfNecessary(type); + + if (javaType == String.class) { + return value.toString(); + } + else if (javaType == Byte.class) { + return Byte.parseByte(value.toString()); + } + else if (javaType == Integer.class) { + BigDecimal bigDecimal = new BigDecimal(value.toString()); + return bigDecimal.intValueExact(); + } + else if (javaType == Short.class) { + return Short.parseShort(value.toString()); + } + else if (javaType == Long.class) { + BigDecimal bigDecimal = new BigDecimal(value.toString()); + return bigDecimal.longValueExact(); + } + else if (javaType == Double.class) { + return Double.parseDouble(value.toString()); + } + else if (javaType == Float.class) { + return Float.parseFloat(value.toString()); + } + else if (javaType == Boolean.class) { + return Boolean.parseBoolean(value.toString()); + } + else if (javaType == Character.class) { + String s = value.toString(); + if (s.length() == 1) { + return s.charAt(0); + } + throw new IllegalArgumentException("Cannot convert to char: " + value); + } + else if (javaType.isEnum()) { + @SuppressWarnings("unchecked") + Class enumType = (Class) javaType; + return Enum.valueOf(enumType, value.toString()); + } + + + String json = JsonParser.toJson(value); + return JsonParser.fromJson(json, javaType); + } + + public static Class resolvePrimitiveIfNecessary(Class type) { + if (type.isPrimitive()) { + return Utils.getWrapperClassForPrimitive(type); + } + return type; + } + +} + diff --git a/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/util/Utils.java b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/util/Utils.java new file mode 100644 index 000000000..258f484c0 --- /dev/null +++ b/labs/arthas-mcp-server/src/main/java/com/taobao/arthas/mcp/server/util/Utils.java @@ -0,0 +1,71 @@ +package com.taobao.arthas.mcp.server.util; + +import java.util.Collection; +import java.util.Map; + +public final class Utils { + + public static boolean hasText(String str) { + return str != null && !str.trim().isEmpty(); + } + + public static boolean isEmpty(Collection collection) { + return (collection == null || collection.isEmpty()); + } + + public static boolean isEmpty(Map map) { + return (map == null || map.isEmpty()); + } + + public static boolean isAssignable(Class targetType, Class sourceType) { + if (targetType == null || sourceType == null) { + return false; + } + + if (targetType.equals(sourceType)) { + return true; + } + + if (targetType.isAssignableFrom(sourceType)) { + return true; + } + + if (targetType.isPrimitive()) { + Class resolvedPrimitive = getPrimitiveClassForWrapper(sourceType); + return resolvedPrimitive != null && targetType.equals(resolvedPrimitive); + } + else if (sourceType.isPrimitive()) { + Class resolvedWrapper = getWrapperClassForPrimitive(sourceType); + return resolvedWrapper != null && targetType.equals(resolvedWrapper); + } + + return false; + } + + public static Class getPrimitiveClassForWrapper(Class wrapperClass) { + if (Boolean.class.equals(wrapperClass)) return boolean.class; + if (Byte.class.equals(wrapperClass)) return byte.class; + if (Character.class.equals(wrapperClass)) return char.class; + if (Double.class.equals(wrapperClass)) return double.class; + if (Float.class.equals(wrapperClass)) return float.class; + if (Integer.class.equals(wrapperClass)) return int.class; + if (Long.class.equals(wrapperClass)) return long.class; + if (Short.class.equals(wrapperClass)) return short.class; + if (Void.class.equals(wrapperClass)) return void.class; + return null; + } + + public static Class getWrapperClassForPrimitive(Class primitiveClass) { + if (boolean.class.equals(primitiveClass)) return Boolean.class; + if (byte.class.equals(primitiveClass)) return Byte.class; + if (char.class.equals(primitiveClass)) return Character.class; + if (double.class.equals(primitiveClass)) return Double.class; + if (float.class.equals(primitiveClass)) return Float.class; + if (int.class.equals(primitiveClass)) return Integer.class; + if (long.class.equals(primitiveClass)) return Long.class; + if (short.class.equals(primitiveClass)) return Short.class; + if (void.class.equals(primitiveClass)) return Void.class; + return null; + } + +} diff --git a/pom.xml b/pom.xml index d00be96a8..11f542a66 100644 --- a/pom.xml +++ b/pom.xml @@ -81,6 +81,7 @@ labs/cluster-management/native-agent-proxy labs/cluster-management/native-agent-common labs/arthas-grpc-server + labs/arthas-mcp-server