feat: Support MCP 2025-03-26 protocol with streaming tools (#3054)

- 实现MCP协议2025-03-26版本支持
- 添加流式工具(Streamable Tools)架构
- 支持双传输模式:流式传输(SSE)和无状态传输
- 实现30+个Arthas工具的MCP接口封装
- 添加实时进度通知和结果流式输出
- 支持watch、monitor、trace、dashboard、profiler等流式工具
This commit is contained in:
Zhang Yu
2025-10-09 17:28:56 +08:00
committed by hengyunabc
parent 2b837ca2c4
commit 367df00e25
83 changed files with 6494 additions and 1538 deletions
@@ -1,9 +1,13 @@
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.common.PidUtils;
import com.taobao.arthas.core.command.model.*;
import com.taobao.arthas.core.distribution.ResultConsumer;
import com.taobao.arthas.core.distribution.ResultDistributor;
import com.taobao.arthas.core.distribution.SharingResultDistributor;
import com.taobao.arthas.core.distribution.impl.PackingResultDistributorImpl;
import com.taobao.arthas.core.distribution.impl.ResultConsumerImpl;
import com.taobao.arthas.core.distribution.impl.SharingResultDistributorImpl;
import com.taobao.arthas.core.shell.cli.CliToken;
import com.taobao.arthas.core.shell.cli.CliTokens;
import com.taobao.arthas.core.shell.cli.Completion;
@@ -16,24 +20,24 @@ 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.core.util.ArthasBanner;
import com.taobao.arthas.core.util.DateUtils;
import com.taobao.arthas.core.util.StringUtils;
import com.taobao.arthas.mcp.server.CommandExecutor;
import io.termd.core.function.Function;
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命令
* 命令执行器,用于执行Arthas命令,支持同步和异步执行
*/
public class CommandExecutorImpl implements CommandExecutor {
private static final Logger logger = LoggerFactory.getLogger(CommandExecutorImpl.class);
private static final String ONETIME_SESSION_KEY = "oneTimeSession";
private final SessionManager sessionManager;
private final JobController jobController;
@@ -45,42 +49,43 @@ public class CommandExecutorImpl implements CommandExecutor {
this.jobController = sessionManager.getJobController();
}
/**
* 同步执行命令
* @param commandLine 命令行
* @param timeout 超时时间(毫秒)
* @return 命令执行结果
*/
@Override
public Map<String, Object> 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<CliToken> 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<String, Object> 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");
public Session getCurrentSession(String sessionId, boolean oneTimeIsAllowed) {
if (sessionId == null || sessionId.trim().isEmpty()) {
if (!oneTimeIsAllowed) {
throw new SessionNotFoundException("SessionId is required for this operation");
}
resultDistributor = new PackingResultDistributorImpl(session);
InMemoryTerm term = new InMemoryTerm();
term.setSession(session);
job = jobController.createJob(commandManager, tokens, session,
new JobHandle(), term, resultDistributor);
Session session = sessionManager.createSession();
if (session == null) {
throw new SessionNotFoundException("Failed to create temporary session");
}
session.put(ONETIME_SESSION_KEY, new Object());
logger.debug("Created one-time session {}", session.getSessionId());
return session;
} else {
Session session = sessionManager.getSession(sessionId);
if (session == null) {
throw new SessionNotFoundException("Session not found: " + sessionId);
}
sessionManager.updateAccessTime(session);
logger.debug("Using existing session {}", sessionId);
return session;
}
}
@Override
public Map<String, Object> executeSync(String commandLine, long timeout, String sessionId) {
Session session = null;
boolean oneTimeAccess = false;
try {
session = getCurrentSession(sessionId, true);
if (session.get(ONETIME_SESSION_KEY) != null) {
oneTimeAccess = true;
}
PackingResultDistributorImpl resultDistributor = new PackingResultDistributorImpl(session);
Job job = this.createJob(commandLine, session, resultDistributor);
if (job == null) {
logger.error("Failed to create job for command: {}", commandLine);
@@ -88,7 +93,6 @@ public class CommandExecutorImpl implements CommandExecutor {
}
job.run();
boolean finished = waitForJob(job, (int) timeout);
if (!finished) {
logger.warn("Command timeout after {} ms: {}", timeout, commandLine);
@@ -96,11 +100,13 @@ public class CommandExecutorImpl implements CommandExecutor {
return createTimeoutResult(commandLine, timeout);
}
Map<String, Object> result = new TreeMap<>();
result.put("command", commandLine);
result.put("success", true);
result.put("sessionId", session.getSessionId());
result.put("executionTime", System.currentTimeMillis());
List<ResultModel> results = resultDistributor.getResults();
List<ResultModel> results = resultDistributor.getResults();
if (results != null && !results.isEmpty()) {
result.put("results", results);
result.put("resultCount", results.size());
@@ -108,28 +114,198 @@ public class CommandExecutorImpl implements CommandExecutor {
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 (SessionNotFoundException e) {
logger.error("Session error for command: {}", commandLine, e);
return createErrorResult(commandLine, e.getMessage());
} catch (Exception e) {
logger.error("Error executing command: {}", commandLine, e);
return createErrorResult(commandLine, "Error executing command: " + e.getMessage());
} finally {
if (session != null) {
if (oneTimeAccess && session != null) {
try {
sessionManager.removeSession(session.getSessionId());
logger.debug("Destroyed one-time session {}", session.getSessionId());
} catch (Exception e) {
logger.warn("Error removing session", e);
logger.warn("Error removing one-time session", e);
}
}
}
}
@Override
public Map<String, Object> executeAsync(String commandLine, String sessionId) {
Map<String, Object> result = new TreeMap<>();
Session session = getCurrentSession(sessionId, false);
if (!session.tryLock()) {
logger.warn("Another command is executing in session: {}", session.getSessionId());
return createErrorResult(commandLine, "Another command is executing");
}
int lock = session.getLock();
try {
Job foregroundJob = session.getForegroundJob();
if (foregroundJob != null) {
logger.warn("Another job is running in session: {}, jobId: {}", session.getSessionId(), foregroundJob.id());
session.unLock();
return createErrorResult(commandLine, "Another job is running, jobId: " + foregroundJob.id());
}
Job job = this.createJob(commandLine, session, session.getResultDistributor());
if (job == null) {
logger.error("Failed to create job for command: {}", commandLine);
session.unLock();
return createErrorResult(commandLine, "Failed to create job");
}
session.setForegroundJob(job);
updateSessionInputStatus(session, InputStatus.ALLOW_INTERRUPT);
job.run();
result.put("success", true);
result.put("command", commandLine);
result.put("sessionId", session.getSessionId());
result.put("jobId", job.id());
result.put("jobStatus", job.status().toString());
return result;
} catch (SessionNotFoundException e) {
logger.error("Session error for async command: {}", commandLine, e);
return createErrorResult(commandLine, e.getMessage());
} catch (Exception e) {
logger.error("Error executing async command: {}", commandLine, e);
return createErrorResult(commandLine, "Error executing async command: " + e.getMessage());
}finally {
if (session.getLock() == lock) {
session.unLock();
}
}
}
@Override
public Map<String, Object> pullResults(String sessionId, String consumerId) {
if (StringUtils.isBlank(consumerId)) {
return createErrorResult(null, "Consumer ID is null or empty");
}
try {
Session session = getCurrentSession(sessionId, false);
SharingResultDistributor resultDistributor = session.getResultDistributor();
if (resultDistributor == null) {
return createErrorResult(null, "No result distributor found for session: " + sessionId);
}
ResultConsumer consumer = resultDistributor.getConsumer(consumerId);
if (consumer == null) {
return createErrorResult(null, "Consumer not found: " + consumerId);
}
List<ResultModel> results = consumer.pollResults();
if (results != null && results.isEmpty()) {
logger.debug("Filtered empty result list for session: {}, consumer: {}", sessionId, consumerId);
return null;
}
Map<String, Object> result = new TreeMap<>();
result.put("success", true);
result.put("sessionId", sessionId);
result.put("consumerId", consumerId);
result.put("results", results);
Job foregroundJob = session.getForegroundJob();
if (foregroundJob != null) {
result.put("jobId", foregroundJob.id());
result.put("jobStatus", foregroundJob.status().toString());
}
return result;
} catch (SessionNotFoundException e) {
return createErrorResult(null, e.getMessage());
}
}
@Override
public Map<String, Object> interruptJob(String sessionId) {
try {
Session session = getCurrentSession(sessionId, false);
Job job = session.getForegroundJob();
if (job == null) {
return createErrorResult(null, "no foreground job is running");
}
job.interrupt();
Map<String, Object> result = new TreeMap<>();
result.put("success", true);
result.put("sessionId", sessionId);
result.put("jobId", job.id());
result.put("jobStatus", job.status().toString());
return result;
} catch (SessionNotFoundException e) {
return createErrorResult(null, e.getMessage());
}
}
@Override
public Map<String, Object> createSession() {
Session session = sessionManager.createSession();
if (session == null) {
return createErrorResult(null, "create api session failed");
}
SharingResultDistributorImpl resultDistributor = new SharingResultDistributorImpl(session);
ResultConsumer resultConsumer = new ResultConsumerImpl();
resultDistributor.addConsumer(resultConsumer);
session.setResultDistributor(resultDistributor);
resultDistributor.appendResult(new MessageModel("Welcome to arthas!"));
WelcomeModel welcomeModel = new WelcomeModel();
welcomeModel.setVersion(ArthasBanner.version());
welcomeModel.setWiki(ArthasBanner.wiki());
welcomeModel.setTutorials(ArthasBanner.tutorials());
welcomeModel.setMainClass(PidUtils.mainClass());
welcomeModel.setPid(PidUtils.currentPid());
welcomeModel.setTime(DateUtils.getCurrentDateTime());
resultDistributor.appendResult(welcomeModel);
updateSessionInputStatus(session, InputStatus.ALLOW_INPUT);
Map<String, Object> result = new TreeMap<>();
result.put("success", true);
result.put("sessionId", session.getSessionId());
result.put("consumerId", resultConsumer.getConsumerId());
return result;
}
@Override
public Map<String, Object> closeSession(String sessionId) {
try {
Session session = getCurrentSession(sessionId, false);
if (session.isLocked()) {
session.unLock();
}
sessionManager.removeSession(session.getSessionId());
Map<String, Object> result = new TreeMap<>();
result.put("success", true);
result.put("sessionId", sessionId);
return result;
} catch (SessionNotFoundException e) {
return createErrorResult(null, e.getMessage());
}
}
private boolean waitForJob(Job job, int timeout) {
long startTime = System.currentTimeMillis();
while (true) {
@@ -150,16 +326,14 @@ public class CommandExecutorImpl implements CommandExecutor {
private Map<String, Object> createErrorResult(String commandLine, String errorMessage) {
Map<String, Object> result = new TreeMap<>();
result.put("command", commandLine);
result.put("success", false);
result.put("error", errorMessage);
result.put("executionTime", System.currentTimeMillis());
if (commandLine != null) {
result.put("command", commandLine);
}
return result;
}
/**
* 创建超时结果
*/
private Map<String, Object> createTimeoutResult(String commandLine, long timeout) {
Map<String, Object> result = new TreeMap<>();
result.put("command", commandLine);
@@ -170,77 +344,73 @@ public class CommandExecutorImpl implements CommandExecutor {
return result;
}
private static class JobHandle implements JobListener {
private final CountDownLatch latch = new CountDownLatch(1);
private void updateSessionInputStatus(Session session, InputStatus inputStatus) {
SharingResultDistributor resultDistributor = session.getResultDistributor();
if (resultDistributor != null) {
resultDistributor.appendResult(new InputStatusModel(inputStatus));
}
}
private Job createJob(String line, Session session, ResultDistributor resultDistributor) {
return createJob(CliTokens.tokenize(line), session, resultDistributor);
}
private synchronized Job createJob(List<CliToken> args, Session session, ResultDistributor resultDistributor) {
Job job = jobController.createJob(commandManager, args, session, new JobHandler(session), new McpTerm(session), resultDistributor);
return job;
}
public static class SessionNotFoundException extends RuntimeException {
public SessionNotFoundException(String message) {
super(message);
}
}
private class JobHandler implements JobListener {
private final Session session;
public JobHandler(Session session) {
this.session = session;
}
@Override
public void onForeground(Job job) {
session.setForegroundJob(job);
}
@Override
public void onBackground(Job job) {
if (session.getForegroundJob() == job) {
session.setForegroundJob(null);
updateSessionInputStatus(session, InputStatus.ALLOW_INPUT);
session.unLock();
}
}
@Override
public void onTerminated(Job job) {
latch.countDown();
if (session.getForegroundJob() == job) {
session.setForegroundJob(null);
updateSessionInputStatus(session, InputStatus.ALLOW_INPUT);
session.unLock();
}
}
@Override
public void onSuspend(Job job) {
}
public boolean await(long timeout, TimeUnit unit) throws InterruptedException {
return latch.await(timeout, unit);
if (session.getForegroundJob() == job) {
session.setForegroundJob(null);
updateSessionInputStatus(session, InputStatus.ALLOW_INPUT);
session.unLock();
}
}
}
public static class InMemoryTerm implements Term {
private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
public static class McpTerm implements Term {
private Session session;
private volatile boolean closed = false;
@Override
public Term setSession(Session session) {
public McpTerm(Session session) {
this.session = session;
return this;
}
public Session getSession() {
return session;
}
@Override
public void readline(String prompt, Handler<String> lineHandler) {
}
@Override
public void readline(String prompt, Handler<String> lineHandler, Handler<Completion> completionHandler) {
}
@Override
public Term closeHandler(Handler<Void> 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
@@ -248,29 +418,48 @@ public class CommandExecutorImpl implements CommandExecutor {
return this;
}
@Override
public String type() {
return "mcp";
}
@Override
public int width() {
return 1000;
}
@Override
public int height() {
return 200;
}
@Override
public Term stdinHandler(Handler<String> handler) {
return this;
}
@Override
public Term stdoutHandler(io.termd.core.function.Function<String, String> handler) {
public Term stdoutHandler(Function<String, String> 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);
}
public Term write(String data) {
return this;
}
@Override
public long lastAccessedTime() {
return session.getLastAccessTime();
}
@Override
public Term echo(String text) {
return this;
}
@Override
public Term setSession(Session session) {
return this;
}
@@ -285,35 +474,23 @@ public class CommandExecutorImpl implements CommandExecutor {
}
@Override
public synchronized void close() {
closed = true;
try {
outputStream.close();
} catch (Exception e) {
logger.error("Error closing output stream", e);
}
public void readline(String prompt, Handler<String> lineHandler) {
}
@Override
public Term echo(String text) {
return write(text);
public void readline(String prompt, Handler<String> lineHandler, Handler<Completion> completionHandler) {
}
public synchronized String getOutput() {
try {
return outputStream.toString(StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
logger.error("Error getting output", e);
return "";
}
@Override
public Term closeHandler(Handler<Void> handler) {
return this;
}
public synchronized void clearOutput() {
outputStream.reset();
}
public boolean isClosed() {
return closed;
@Override
public void close() {
}
}
}
@@ -80,8 +80,7 @@ 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 com.taobao.arthas.mcp.server.protocol.server.handler.McpHttpRequestHandler;
import io.netty.channel.ChannelFuture;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.util.concurrent.DefaultThreadFactory;
@@ -130,7 +129,7 @@ public class ArthasBootstrap {
private HttpApiHandler httpApiHandler;
private McpRequestHandler mcpRequestHandler;
private McpHttpRequestHandler mcpRequestHandler;
private HttpSessionManager httpSessionManager;
private SecurityAuthenticator securityAuthenticator;
@@ -683,7 +682,7 @@ public class ArthasBootstrap {
return httpApiHandler;
}
public McpRequestHandler getMcpRequestHandler() {
public McpHttpRequestHandler getMcpRequestHandler() {
return mcpRequestHandler;
}
@@ -5,7 +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 com.taobao.arthas.mcp.server.protocol.server.handler.McpHttpRequestHandler;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
@@ -38,7 +38,7 @@ public class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequ
private HttpApiHandler httpApiHandler;
private McpRequestHandler mcpRequestHandler;
private McpHttpRequestHandler mcpRequestHandler;
public HttpRequestHandler(String wsUri) {
this(wsUri, ArthasBootstrap.getInstance().getOutputPath());
@@ -76,9 +76,8 @@ public class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequ
}
//handle mcp request
String messageEndpoint = mcpRequestHandler.getMessageEndpoint();
String sseEndpoint = mcpRequestHandler.getSseEndpoint();
if (sseEndpoint.equals(path) || messageEndpoint.equals(path)) {
String mcpEndpoint = mcpRequestHandler.getMcpEndpoint();
if (mcpEndpoint.equals(path)) {
mcpRequestHandler.handle(ctx, request);
isMcpHandled = true;
return;
+5 -2
View File
@@ -41,8 +41,11 @@
<artifactId>jackson-databind</artifactId>
<version>2.18.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.18.1</version>
</dependency>
<!-- log dependencies -->
<dependency>
@@ -29,11 +29,18 @@ public class ArthasMcpBootstrap {
}
public ArthasMcpServer start() {
logger.info("Initializing Arthas MCP Bootstrap...");
try {
// Create and start MCP server
mcpServer = new ArthasMcpServer();
logger.debug("Creating MCP server instance with command executor: {}",
commandExecutor.getClass().getSimpleName());
// Create and start MCP server with CommandExecutor
mcpServer = new ArthasMcpServer(commandExecutor);
logger.debug("MCP server instance created successfully");
mcpServer.start();
logger.info("Arthas MCP server initialized successfully");
logger.info("Bootstrap ready - server is operational");
return mcpServer;
} catch (Exception e) {
logger.error("Failed to initialize Arthas MCP server", e);
@@ -42,8 +49,14 @@ public class ArthasMcpBootstrap {
}
public void shutdown() {
logger.info("Initiating Arthas MCP Bootstrap shutdown...");
if (mcpServer != null) {
logger.debug("Stopping MCP server...");
mcpServer.stop();
logger.info("MCP server stopped");
} else {
logger.warn("MCP server was null during shutdown - may not have been properly initialized");
}
logger.info("Arthas MCP Bootstrap shutdown completed");
}
}
@@ -4,20 +4,24 @@ 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.protocol.server.McpStatelessNettyServer;
import com.taobao.arthas.mcp.server.protocol.server.handler.McpHttpRequestHandler;
import com.taobao.arthas.mcp.server.protocol.server.handler.McpStatelessHttpRequestHandler;
import com.taobao.arthas.mcp.server.protocol.server.handler.McpStreamableHttpRequestHandler;
import com.taobao.arthas.mcp.server.protocol.server.transport.NettyStatelessServerTransport;
import com.taobao.arthas.mcp.server.protocol.server.transport.NettyStreamableServerTransportProvider;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema.Implementation;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema.ServerCapabilities;
import com.taobao.arthas.mcp.server.protocol.spec.McpStreamableServerTransportProvider;
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 com.taobao.arthas.mcp.server.util.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.*;
import java.util.stream.Collectors;
/**
@@ -26,54 +30,52 @@ import java.util.stream.Collectors;
*/
public class ArthasMcpServer {
private static final Logger logger = LoggerFactory.getLogger(ArthasMcpServer.class);
private McpNettyServer server;
private McpNettyServer streamableServer;
private McpStatelessNettyServer statelessServer;
private final int port;
private final String bindAddress;
private McpRequestHandler mcpRequestHandler;
private final CommandExecutor commandExecutor;
private McpHttpRequestHandler unifiedMcpHandler;
private McpStreamableHttpRequestHandler streamableHandler;
private McpStatelessHttpRequestHandler statelessHandler;
public ArthasMcpServer() {
this(8080, "localhost");
public ArthasMcpServer(CommandExecutor commandExecutor) {
this(8080, "localhost", commandExecutor);
}
public ArthasMcpServer(int port, String bindAddress) {
public ArthasMcpServer(int port, String bindAddress, CommandExecutor commandExecutor) {
this.port = port;
this.bindAddress = bindAddress;
this.commandExecutor = commandExecutor;
}
public McpRequestHandler getMcpRequestHandler() {
return mcpRequestHandler;
public McpHttpRequestHandler getMcpRequestHandler() {
return unifiedMcpHandler;
}
/**
* Start MCP server
*/
public void start() {
logger.info("Starting Arthas MCP server on {}:{}", bindAddress, port);
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")
.mcpEndpoint("/mcp")
.toolChangeNotification(true)
.resourceChangeNotification(true)
.promptChangeNotification(true)
.objectMapper(new ObjectMapper())
.objectMapper(JsonParser.getObjectMapper())
.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();
@@ -81,11 +83,53 @@ public class ArthasMcpServer {
.filter(Objects::nonNull)
.collect(Collectors.toList());
serverBuilder.tools(
McpToolUtils.toToolSpecifications(providerToolCallbacks, properties));
server = serverBuilder.build();
// Create transport for both streamable and stateless servers
McpStreamableServerTransportProvider transportProvider = createStreamableHttpTransportProvider(properties);
streamableHandler = transportProvider.getMcpRequestHandler();
NettyStatelessServerTransport statelessTransport = createStatelessHttpTransport(properties);
statelessHandler = statelessTransport.getMcpRequestHandler();
unifiedMcpHandler = McpHttpRequestHandler.builder()
.mcpEndpoint(properties.getMcpEndpoint())
.objectMapper(properties.getObjectMapper())
.tools(Arrays.asList(callbacks))
.build();
unifiedMcpHandler.setStreamableHandler(streamableHandler);
unifiedMcpHandler.setStatelessHandler(statelessHandler);
// Set up unified MCP handler for both streamable and stateless servers
McpServer.StreamableServerNettySpecification streamableServerNettySpecification = McpServer.netty(transportProvider)
.serverInfo(new Implementation(properties.getName(), properties.getVersion()))
.capabilities(buildServerCapabilities(properties))
.instructions(properties.getInstructions())
.requestTimeout(properties.getRequestTimeout())
.commandExecutor(commandExecutor)
.objectMapper(properties.getObjectMapper() != null ? properties.getObjectMapper() : JsonParser.getObjectMapper());
// Set up unified MCP handler for both streamable and stateless servers
McpServer.StatelessServerNettySpecification statelessServerNettySpecification = McpServer.netty(statelessTransport)
.serverInfo(new Implementation(properties.getName(), properties.getVersion()))
.capabilities(buildServerCapabilities(properties))
.instructions(properties.getInstructions())
.requestTimeout(properties.getRequestTimeout())
.commandExecutor(commandExecutor)
.objectMapper(properties.getObjectMapper() != null ? properties.getObjectMapper() : JsonParser.getObjectMapper());
streamableServerNettySpecification.tools(
McpToolUtils.toStreamableToolSpecifications(providerToolCallbacks));
statelessServerNettySpecification.tools(
McpToolUtils.toStatelessToolSpecifications(providerToolCallbacks));
streamableServer = streamableServerNettySpecification.build();
statelessServer = statelessServerNettySpecification.build();
logger.info("Arthas MCP server started, listening on {}:{}", bindAddress, port);
logger.info("Arthas MCP server started successfully");
logger.info("- Listening on {}:{}", bindAddress, port);
logger.info("- MCP Endpoint: {}", properties.getMcpEndpoint());
logger.info("- Transport modes: Streamable + Stateless");
logger.info("- Available tools: {}", providerToolCallbacks.size());
logger.info("- Server ready to accept connections");
} catch (Exception e) {
logger.error("Failed to start Arthas MCP server", e);
throw new RuntimeException("Failed to start Arthas MCP server", e);
@@ -95,17 +139,20 @@ public class ArthasMcpServer {
/**
* Create HTTP transport provider
*/
private HttpNettyServerTransportProvider createHttpTransportProvider(McpServerProperties properties) {
return HttpNettyServerTransportProvider.builder()
.messageEndpoint(properties.getMessageEndpoint())
.sseEndpoint(properties.getSseEndpoint())
private NettyStreamableServerTransportProvider createStreamableHttpTransportProvider(McpServerProperties properties) {
return NettyStreamableServerTransportProvider.builder()
.mcpEndpoint(properties.getMcpEndpoint())
.objectMapper(properties.getObjectMapper() != null ? properties.getObjectMapper() : new ObjectMapper())
.build();
}
private NettyStatelessServerTransport createStatelessHttpTransport(McpServerProperties properties) {
return NettyStatelessServerTransport.builder()
.mcpEndpoint(properties.getMcpEndpoint())
.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()))
@@ -113,29 +160,31 @@ public class ArthasMcpServer {
.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
public void stop() {
logger.info("Stopping Arthas MCP server...");
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
if (unifiedMcpHandler != null) {
logger.debug("Shutting down unified MCP handler");
unifiedMcpHandler.closeGracefully().get();
logger.info("Unified MCP handler stopped successfully");
}
if (streamableServer != null) {
logger.debug("Shutting down streamable server");
streamableServer.closeGracefully().get();
logger.info("Streamable server stopped successfully");
}
if (statelessServer != null) {
logger.debug("Shutting down stateless server");
statelessServer.closeGracefully().get();
logger.info("Stateless server stopped successfully");
}
logger.info("Arthas MCP server stopped completely");
} catch (Exception e) {
logger.error("Failed to stop Arthas MCP server gracefully", e);
}
}
}
@@ -2,8 +2,27 @@ package com.taobao.arthas.mcp.server;
import java.util.Map;
/**
* 命令执行器接口
*
* @author Yeaury 2025/5/26
*/
public interface CommandExecutor {
Map<String, Object> execute(String commandLine, long timeout);
default Map<String, Object> executeSync(String commandLine, long timeout) {
return executeSync(commandLine, timeout, null);
}
Map<String, Object> executeSync(String commandLine, long timeout, String sessionId);
Map<String, Object> executeAsync(String commandLine, String sessionId);
Map<String, Object> pullResults(String sessionId, String consumerId);
Map<String, Object> interruptJob(String sessionId);
Map<String, Object> createSession();
Map<String, Object> closeSession(String sessionId);
}
@@ -1,7 +1,6 @@
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;
@@ -35,8 +34,7 @@ public class McpServerProperties {
*/
private final String bindAddress;
private final int port;
private final String messageEndpoint;
private final String sseEndpoint;
private final String mcpEndpoint;
/**
* Timeout configuration
@@ -46,6 +44,11 @@ public class McpServerProperties {
private final ObjectMapper objectMapper;
/**
* (Optional) response MIME type per tool name.
*/
private Map<String, String> toolResponseMimeType = new HashMap<>();
/**
* Private constructor, can only be created through Builder
*/
@@ -59,8 +62,7 @@ public class McpServerProperties {
this.resourceSubscribe = builder.resourceSubscribe;
this.bindAddress = builder.bindAddress;
this.port = builder.port;
this.messageEndpoint = builder.messageEndpoint;
this.sseEndpoint = builder.sseEndpoint;
this.mcpEndpoint = builder.mcpEndpoint;
this.requestTimeout = builder.requestTimeout;
this.initializationTimeout = builder.initializationTimeout;
this.objectMapper = builder.objectMapper;
@@ -145,20 +147,13 @@ public class McpServerProperties {
return port;
}
/**
* Get message endpoint
* @return Message endpoint
*/
public String getMessageEndpoint() {
return messageEndpoint;
}
/**
* Get SSE endpoint
* @return SSE endpoint
*/
public String getSseEndpoint() {
return sseEndpoint;
public String getMcpEndpoint() {
return mcpEndpoint;
}
/**
@@ -185,6 +180,14 @@ public class McpServerProperties {
return objectMapper;
}
public Map<String, String> getToolResponseMimeType() {
return toolResponseMimeType;
}
public void setToolResponseMimeType(Map<String, String> toolResponseMimeType) {
this.toolResponseMimeType = toolResponseMimeType;
}
/**
* Builder class for McpServerProperties
*/
@@ -194,13 +197,12 @@ public class McpServerProperties {
private String version = "1.0.0";
private String instructions;
private boolean toolChangeNotification = true;
private boolean resourceChangeNotification = true;
private boolean promptChangeNotification = true;
private boolean resourceChangeNotification = false;
private boolean promptChangeNotification = false;
private boolean resourceSubscribe = false;
private String bindAddress = "localhost";
private int port = 8080;
private String messageEndpoint = "/mcp/message";
private String sseEndpoint = "/mcp/sse";
private String mcpEndpoint = "/mcp";
private Duration requestTimeout = Duration.ofSeconds(10);
private Duration initializationTimeout = Duration.ofSeconds(30);
private ObjectMapper objectMapper;
@@ -254,13 +256,8 @@ public class McpServerProperties {
return this;
}
public Builder messageEndpoint(String messageEndpoint) {
this.messageEndpoint = messageEndpoint;
return this;
}
public Builder sseEndpoint(String sseEndpoint) {
this.sseEndpoint = sseEndpoint;
public Builder mcpEndpoint(String mcpEndpoint) {
this.mcpEndpoint = mcpEndpoint;
return this;
}
@@ -0,0 +1,85 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
import com.taobao.arthas.mcp.server.CommandExecutor;
import com.taobao.arthas.mcp.server.protocol.spec.McpError;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
class DefaultMcpStatelessServerHandler implements McpStatelessServerHandler {
private static final Logger logger = LoggerFactory.getLogger(DefaultMcpStatelessServerHandler.class);
Map<String, McpStatelessRequestHandler<?>> requestHandlers;
Map<String, McpStatelessNotificationHandler> notificationHandlers;
private final CommandExecutor commandExecutor;
public DefaultMcpStatelessServerHandler(Map<String, McpStatelessRequestHandler<?>> requestHandlers,
Map<String, McpStatelessNotificationHandler> notificationHandlers,
CommandExecutor commandExecutor) {
this.requestHandlers = requestHandlers;
this.notificationHandlers = notificationHandlers;
this.commandExecutor = commandExecutor;
}
@Override
public CompletableFuture<McpSchema.JSONRPCResponse> handleRequest(McpTransportContext ctx, McpSchema.JSONRPCRequest req) {
ArthasCommandContext commandContext = createCommandContext();
McpStatelessRequestHandler<?> handler = requestHandlers.get(req.getMethod());
if (handler == null) {
CompletableFuture<McpSchema.JSONRPCResponse> f = new CompletableFuture<>();
f.completeExceptionally(new McpError("Missing handler for request type: " + req.getMethod()));
return f;
}
try {
@SuppressWarnings("unchecked")
CompletableFuture<Object> result = (CompletableFuture<Object>) handler
.handle(ctx, commandContext, req.getParams());
return result.handle((r, ex) -> {
if (ex != null) {
Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex;
return new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, req.getId(), null,
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, cause.getMessage(), null));
}
return new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, req.getId(), r, null);
});
} catch (Throwable t) {
CompletableFuture<McpSchema.JSONRPCResponse> f = new CompletableFuture<>();
f.completeExceptionally(t);
return f;
}
}
@Override
public CompletableFuture<Void> handleNotification(McpTransportContext ctx,
McpSchema.JSONRPCNotification note) {
McpStatelessNotificationHandler handler = notificationHandlers.get(note.getMethod());
if (handler == null) {
logger.warn("Missing handler for notification: {}", note.getMethod());
return CompletableFuture.completedFuture(null);
}
try {
return handler.handle(ctx, note.getParams());
} catch (Throwable t) {
CompletableFuture<Void> f = new CompletableFuture<>();
f.completeExceptionally(t);
return f;
}
}
private ArthasCommandContext createCommandContext() {
return new ArthasCommandContext(commandExecutor);
}
}
@@ -0,0 +1,47 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Default implementation for {@link McpTransportContext} which uses a Thread-safe map.
* Objects of this kind are mutable.
*/
public class DefaultMcpTransportContext implements McpTransportContext {
private final Map<String, Object> storage;
/**
* Create an empty instance.
*/
public DefaultMcpTransportContext() {
this.storage = new ConcurrentHashMap<>();
}
DefaultMcpTransportContext(Map<String, Object> storage) {
this.storage = storage;
}
@Override
public Object get(String key) {
return this.storage.get(key);
}
@Override
public void put(String key, Object value) {
this.storage.put(key, value);
}
/**
* Allows copying the contents.
* @return new instance with the copy of the underlying map
*/
public McpTransportContext copy() {
return new DefaultMcpTransportContext(new ConcurrentHashMap<>(this.storage));
}
}
@@ -0,0 +1,24 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import java.util.concurrent.CompletableFuture;
/**
* Handles MCP initialization requests from clients using CompletableFuture for async operations.
* This is the Netty-specific version that doesn't depend on Reactor.
*/
public interface McpInitRequestHandler {
/**
* Handles the initialization request.
* @param initializeRequest the initialization request by the client
* @return a CompletableFuture that will emit the result of the initialization
*/
CompletableFuture<McpSchema.InitializeResult> handle(McpSchema.InitializeRequest initializeRequest);
}
@@ -1,5 +1,18 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
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.CommandExecutor;
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@@ -8,15 +21,6 @@ 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.
*
@@ -46,13 +50,12 @@ public class McpNettyServer {
private McpSchema.LoggingLevel minLoggingLevel = McpSchema.LoggingLevel.DEBUG;
private List<String> protocolVersions = Collections.singletonList(McpSchema.LATEST_PROTOCOL_VERSION);
private List<String> protocolVersions;
public McpNettyServer(
McpServerTransportProvider mcpTransportProvider,
ObjectMapper objectMapper,
Duration requestTimeout,
McpServerFeatures.McpServerConfig features) {
McpNettyServer(McpStreamableServerTransportProvider mcpTransportProvider,
ObjectMapper objectMapper, Duration requestTimeout,
McpServerFeatures.McpServerConfig features,
CommandExecutor commandExecutor) {
this.mcpTransportProvider = mcpTransportProvider;
this.objectMapper = objectMapper;
this.serverInfo = features.getServerInfo();
@@ -63,13 +66,44 @@ public class McpNettyServer {
this.resourceTemplates.addAll(features.getResourceTemplates());
this.prompts.putAll(features.getPrompts());
Map<String, McpServerSession.RequestHandler<?>> requestHandlers = new HashMap<>();
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
this.protocolVersions = Collections.singletonList(mcpTransportProvider.protocolVersion());
mcpTransportProvider.setSessionFactory(new DefaultMcpStreamableServerSessionFactory(requestTimeout,
this::initializeRequestHandler, requestHandlers, notificationHandlers, commandExecutor));
}
private Map<String, McpNotificationHandler> prepareNotificationHandlers(McpServerFeatures.McpServerConfig features) {
Map<String, McpNotificationHandler> notificationHandlers = new HashMap<>();
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED,
(exchange, commandContext, params) -> CompletableFuture.completedFuture(null));
List<BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>> 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));
return notificationHandlers;
}
private Map<String, McpRequestHandler<?>> prepareRequestHandlers() {
Map<String, McpRequestHandler<?>> 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()));
(exchange, commandContext, params) -> CompletableFuture.completedFuture(Collections.emptyMap()));
// Add tools API handlers if the tool capability is enabled
if (this.serverCapabilities.getTools() != null) {
@@ -90,37 +124,15 @@ public class McpNettyServer {
requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler());
}
// Add logging API handlers if the logging capability is enabled
if (this.serverCapabilities.getLogging() != null) {
requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler());
}
Map<String, McpServerSession.NotificationHandler> notificationHandlers = new HashMap<>();
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED,
(exchange, params) -> CompletableFuture.completedFuture(null));
List<BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>> 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);
});
return requestHandlers;
}
// ---------------------------------------
// Lifecycle Management
// ---------------------------------------
@@ -165,9 +177,9 @@ public class McpNettyServer {
this.mcpTransportProvider.close();
}
private McpServerSession.NotificationHandler rootsListChangedNotificationHandler(
private McpNotificationHandler rootsListChangedNotificationHandler(
List<BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>> rootsChangeConsumers) {
return (exchange, params) -> {
return (exchange, commandContext, params) -> {
CompletableFuture<McpSchema.ListRootsResult> futureRoots = exchange.listRoots();
return futureRoots.thenCompose(listRootsResult -> {
@@ -268,8 +280,8 @@ public class McpNettyServer {
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
}
private McpServerSession.RequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
return (exchange, params) -> {
private McpRequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
return (exchange, commandContext, params) -> {
List<McpSchema.Tool> tools = new ArrayList<>();
for (McpServerFeatures.ToolSpecification toolSpec : this.tools) {
tools.add(toolSpec.getTool());
@@ -279,8 +291,8 @@ public class McpNettyServer {
};
}
private McpServerSession.RequestHandler<McpSchema.CallToolResult> toolsCallRequestHandler() {
return (exchange, params) -> {
private McpRequestHandler<McpSchema.CallToolResult> toolsCallRequestHandler() {
return (exchange, commandContext, params) -> {
McpSchema.CallToolRequest callToolRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.CallToolRequest>() {
});
@@ -294,7 +306,7 @@ public class McpNettyServer {
return future;
}
return toolSpecification.get().getCall().apply(exchange, callToolRequest.getArguments());
return toolSpecification.get().getCall().apply(exchange, commandContext, callToolRequest);
};
}
@@ -369,8 +381,8 @@ public class McpNettyServer {
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null);
}
private McpServerSession.RequestHandler<McpSchema.ListResourcesResult> resourcesListRequestHandler() {
return (exchange, params) -> {
private McpRequestHandler<McpSchema.ListResourcesResult> resourcesListRequestHandler() {
return (exchange, commandContext, params) -> {
List<McpSchema.Resource> resourceList = new ArrayList<>();
for (McpServerFeatures.ResourceSpecification spec : this.resources.values()) {
resourceList.add(spec.getResource());
@@ -379,13 +391,13 @@ public class McpNettyServer {
};
}
private McpServerSession.RequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplateListRequestHandler() {
return (exchange, params) -> CompletableFuture
private McpRequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplateListRequestHandler() {
return (exchange, commandContext, params) -> CompletableFuture
.completedFuture(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null));
}
private McpServerSession.RequestHandler<McpSchema.ReadResourceResult> resourcesReadRequestHandler() {
return (exchange, params) -> {
private McpRequestHandler<McpSchema.ReadResourceResult> resourcesReadRequestHandler() {
return (exchange, commandContext, params) -> {
McpSchema.ReadResourceRequest resourceRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.ReadResourceRequest>() {
});
@@ -473,8 +485,8 @@ public class McpNettyServer {
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
}
private McpServerSession.RequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler() {
return (exchange, params) -> {
private McpRequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler() {
return (exchange, commandContext, params) -> {
List<McpSchema.Prompt> promptList = new ArrayList<>();
for (McpServerFeatures.PromptSpecification promptSpec : this.prompts.values()) {
promptList.add(promptSpec.getPrompt());
@@ -483,8 +495,8 @@ public class McpNettyServer {
};
}
private McpServerSession.RequestHandler<McpSchema.GetPromptResult> promptsGetRequestHandler() {
return (exchange, params) -> {
private McpRequestHandler<McpSchema.GetPromptResult> promptsGetRequestHandler() {
return (exchange, commandContext, params) -> {
McpSchema.GetPromptRequest promptRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.GetPromptRequest>() {
});
@@ -514,8 +526,8 @@ public class McpNettyServer {
loggingMessageNotification);
}
private McpServerSession.RequestHandler<Map<String, Object>> setLoggerRequestHandler() {
return (exchange, params) -> {
private McpRequestHandler<Map<String, Object>> setLoggerRequestHandler() {
return (exchange, commandContext, params) -> {
try {
McpSchema.SetLevelRequest request = this.objectMapper.convertValue(params,
McpSchema.SetLevelRequest.class);
@@ -1,25 +1,26 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
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.protocol.spec.McpSession;
import com.taobao.arthas.mcp.server.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
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.
* This class is focused only on MCP protocol communication and does not handle command execution directly.
*
* @author Yeaury
* <p>
* McpNettyServerExchange provides various methods for communicating with the client, including:
* <ul>
@@ -28,6 +29,7 @@ import java.util.concurrent.atomic.AtomicLong;
* <li>Handling logging notifications
* <li>Creating client messages
* <li>Managing root directories
* <li>Streamable task management
* </ul>
* <p>
* Each exchange object is associated with a specific client session, providing context and capabilities for that session.
@@ -36,12 +38,16 @@ public class McpNettyServerExchange {
private static final Logger logger = LoggerFactory.getLogger(McpNettyServerExchange.class);
private final McpServerSession session;
private final String sessionId;
private final McpSession session;
private final McpSchema.ClientCapabilities clientCapabilities;
private final McpSchema.Implementation clientInfo;
private final McpTransportContext transportContext;
private volatile LoggingLevel minLoggingLevel = LoggingLevel.INFO;
private static final TypeReference<McpSchema.CreateMessageResult> CREATE_MESSAGE_RESULT_TYPE_REF =
@@ -52,35 +58,18 @@ public class McpNettyServerExchange {
new TypeReference<McpSchema.ListRootsResult>() {
};
/**
* 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");
public static final TypeReference<Object> OBJECT_TYPE_REF = new TypeReference<Object>() {
};
public McpNettyServerExchange(String sessionId, McpSession session,
McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo,
McpTransportContext transportContext) {
this.sessionId = sessionId;
this.session = session;
this.clientCapabilities = clientCapabilities;
this.clientInfo = clientInfo;
logger.debug("Created new server exchange, session ID: {}, client: {}", session.getId(), clientInfo);
this.transportContext = transportContext;
}
/**
* 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
@@ -98,23 +87,11 @@ public class McpNettyServerExchange {
}
/**
* 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
* Get the MCP server session associated with this exchange.
* @return The MCP server session
*/
public CompletableFuture<Void> 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());
}
});
public McpSession getSession() {
return this.session;
}
/**
@@ -141,15 +118,15 @@ public class McpNettyServerExchange {
return future;
}
logger.debug("Creating client message, session ID: {}", this.session.getId());
logger.debug("Creating client message, session ID: {}", this.sessionId);
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());
logger.error("Failed to create message, session ID: {}, error: {}", this.sessionId, error.getMessage());
}
else {
logger.debug("Message created successfully, session ID: {}", this.session.getId());
logger.debug("Message created successfully, session ID: {}", this.sessionId);
}
});
}
@@ -168,16 +145,16 @@ public class McpNettyServerExchange {
* @return Emits a CompletableFuture containing the results of the root list
*/
public CompletableFuture<McpSchema.ListRootsResult> listRoots(String cursor) {
logger.debug("Requesting root list, session ID: {}, cursor: {}", this.session.getId(), cursor);
logger.debug("Requesting root list, session ID: {}, cursor: {}", this.sessionId, 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());
logger.error("Failed to get root list, session ID: {}, error: {}", this.sessionId, error.getMessage());
}
else {
logger.debug("Root list retrieved successfully, session ID: {}", this.session.getId());
logger.debug("Root list retrieved successfully, session ID: {}", this.sessionId);
}
});
}
@@ -194,16 +171,20 @@ public class McpNettyServerExchange {
.whenComplete((result, error) -> {
if (error != null) {
logger.error("Failed to send logging notification, level: {}, session ID: {}, error: {}", loggingMessageNotification.getLevel(),
this.session.getId(), error.getMessage());
this.sessionId, error.getMessage());
}
});
}
return CompletableFuture.completedFuture(null);
}
public CompletableFuture<Object> ping() {
return this.session.sendRequest(McpSchema.METHOD_PING, null, OBJECT_TYPE_REF);
}
public void setMinLoggingLevel(LoggingLevel minLoggingLevel) {
Assert.notNull(minLoggingLevel, "最低日志级别不能为空");
logger.debug("Setting minimum logging level: {}, session ID: {}", minLoggingLevel, this.session.getId());
logger.debug("Setting minimum logging level: {}, session ID: {}", minLoggingLevel, this.sessionId);
this.minLoggingLevel = minLoggingLevel;
}
@@ -211,4 +192,21 @@ public class McpNettyServerExchange {
return loggingLevel.level() >= this.minLoggingLevel.level();
}
public CompletableFuture<Void> progressNotification(McpSchema.ProgressNotification progressNotification) {
if (progressNotification == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("进度通知不能为空"));
return future;
}
return this.session
.sendNotification(McpSchema.METHOD_NOTIFICATION_PROGRESS, progressNotification)
.whenComplete((result, error) -> {
if (error != null) {
logger.error("Failed to send progress notification, session ID: {}, error: {}", this.sessionId, error.getMessage());
} else {
logger.debug("Progress notification sent successfully, session ID: {}", this.sessionId);
}
});
}
}
@@ -0,0 +1,26 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import java.util.concurrent.CompletableFuture;
/**
* Handles MCP notifications from clients using CompletableFuture for async operations.
* This is the Netty-specific version that doesn't depend on Reactor.
*/
public interface McpNotificationHandler {
/**
* Handles a notification from the client.
* @param exchange the exchange associated with the client that allows calling back to
* the connected client or inspecting its capabilities.
* @param params the parameters of the notification.
* @return a CompletableFuture that completes once the notification is handled.
*/
CompletableFuture<Void> handle(McpNettyServerExchange exchange, ArthasCommandContext arthasCommandContext, Object params);
}
@@ -0,0 +1,26 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import java.util.concurrent.CompletableFuture;
/**
* Handles MCP requests from clients using CompletableFuture for async operations.
* This is the Netty-specific version that doesn't depend on Reactor.
*/
public interface McpRequestHandler<T> {
/**
* Handles a request from the client.
* @param exchange the exchange associated with the client that allows calling back to
* the connected client or inspecting its capabilities.
* @param params the parameters of the request.
* @return a CompletableFuture that will emit the response to the request.
*/
CompletableFuture<T> handle(McpNettyServerExchange exchange, ArthasCommandContext arthasCommandContext, Object params);
}
@@ -1,7 +1,15 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
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.JsonParser;
import com.taobao.arthas.mcp.server.CommandExecutor;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import com.taobao.arthas.mcp.server.protocol.spec.McpStatelessServerTransport;
import com.taobao.arthas.mcp.server.protocol.spec.McpStreamableServerTransportProvider;
import com.taobao.arthas.mcp.server.util.Assert;
import java.time.Duration;
@@ -16,106 +24,77 @@ import java.util.function.BiFunction;
*/
public interface McpServer {
static NettySpecification netty(McpServerTransportProvider transportProvider) {
return new NettySpecification(transportProvider);
McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server", "1.0.0");
static StreamableServerNettySpecification netty(McpStreamableServerTransportProvider transportProvider) {
return new StreamableServerNettySpecification(transportProvider);
}
/**
* serverSpecification
*/
class NettySpecification {
static StatelessServerNettySpecification netty(McpStatelessServerTransport transport) {
return new StatelessServerNettySpecification(transport);
}
private static final McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server",
"1.0.0");
class StreamableServerNettySpecification {
private final McpServerTransportProvider transportProvider;
ObjectMapper objectMapper;
private ObjectMapper objectMapper;
McpSchema.Implementation serverInfo = DEFAULT_SERVER_INFO;
private McpSchema.Implementation serverInfo = DEFAULT_SERVER_INFO;
McpSchema.ServerCapabilities serverCapabilities;
private McpSchema.ServerCapabilities serverCapabilities;
String instructions;
private String instructions;
CommandExecutor commandExecutor;
/**
* 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<McpServerFeatures.ToolSpecification> tools = new ArrayList<>();
private final McpStreamableServerTransportProvider transportProvider;
/**
* 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<String, McpServerFeatures.ResourceSpecification> resources = new HashMap<>();
final List<McpServerFeatures.ToolSpecification> tools = new ArrayList<>();
private final List<McpSchema.ResourceTemplate> resourceTemplates = new ArrayList<>();
final Map<String, McpServerFeatures.ResourceSpecification> resources = new HashMap<>();
/**
* 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<String, McpServerFeatures.PromptSpecification> prompts = new HashMap<>();
final List<McpSchema.ResourceTemplate> resourceTemplates = new ArrayList<>();
private final List<BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>> rootsChangeHandlers = new ArrayList<>();
final Map<String, McpServerFeatures.PromptSpecification> prompts = new HashMap<>();
private Duration requestTimeout = Duration.ofSeconds(10); // Default timeout
final List<BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>> rootsChangeHandlers = new ArrayList<>();
private NettySpecification(McpServerTransportProvider transportProvider) {
Assert.notNull(transportProvider, "Transport provider must not be null");
Duration requestTimeout = Duration.ofSeconds(10); // Default timeout
public StreamableServerNettySpecification(McpStreamableServerTransportProvider transportProvider) {
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) {
public StreamableServerNettySpecification serverInfo(McpSchema.Implementation serverInfo) {
Assert.notNull(serverInfo, "Server info must not be null");
this.serverInfo = serverInfo;
return this;
}
public NettySpecification requestTimeout(Duration requestTimeout) {
public StreamableServerNettySpecification requestTimeout(Duration requestTimeout) {
Assert.notNull(requestTimeout, "Request timeout must not be null");
this.requestTimeout = requestTimeout;
return this;
}
public NettySpecification serverInfo(String name, String version) {
public StreamableServerNettySpecification 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) {
public StreamableServerNettySpecification instructions(String instructions) {
this.instructions = instructions;
return this;
}
public NettySpecification capabilities(McpSchema.ServerCapabilities serverCapabilities) {
public StreamableServerNettySpecification capabilities(McpSchema.ServerCapabilities serverCapabilities) {
this.serverCapabilities = serverCapabilities;
return this;
}
public NettySpecification tool(McpSchema.Tool tool,
BiFunction<McpNettyServerExchange, Map<String, Object>, CompletableFuture<McpSchema.CallToolResult>> handler) {
public StreamableServerNettySpecification tool(McpSchema.Tool tool,
McpServerFeatures.ToolCallFunction handler) {
Assert.notNull(tool, "Tool must not be null");
Assert.notNull(handler, "Handler must not be null");
@@ -124,26 +103,24 @@ public interface McpServer {
return this;
}
public NettySpecification tools(List<McpServerFeatures.ToolSpecification> toolRegistrations) {
public StreamableServerNettySpecification tools(List<McpServerFeatures.ToolSpecification> 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);
}
public StreamableServerNettySpecification tools(McpServerFeatures.ToolSpecification... toolRegistrations) {
this.tools.addAll(Arrays.asList(toolRegistrations));
return this;
}
public NettySpecification resources(Map<String, McpServerFeatures.ResourceSpecification> resourceSpecifications) {
public StreamableServerNettySpecification resources(Map<String, McpServerFeatures.ResourceSpecification> resourceSpecifications) {
Assert.notNull(resourceSpecifications, "Resource handlers map must not be null");
this.resources.putAll(resourceSpecifications);
return this;
}
public NettySpecification resources(List<McpServerFeatures.ResourceSpecification> resourceSpecifications) {
public StreamableServerNettySpecification resources(List<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);
@@ -151,7 +128,7 @@ public interface McpServer {
return this;
}
public NettySpecification resources(McpServerFeatures.ResourceSpecification... resourceSpecifications) {
public StreamableServerNettySpecification 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);
@@ -159,25 +136,25 @@ public interface McpServer {
return this;
}
public NettySpecification resourceTemplates(List<McpSchema.ResourceTemplate> resourceTemplates) {
public StreamableServerNettySpecification resourceTemplates(List<McpSchema.ResourceTemplate> resourceTemplates) {
Assert.notNull(resourceTemplates, "Resource templates must not be null");
this.resourceTemplates.addAll(resourceTemplates);
return this;
}
public NettySpecification resourceTemplates(McpSchema.ResourceTemplate... resourceTemplates) {
public StreamableServerNettySpecification 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<String, McpServerFeatures.PromptSpecification> prompts) {
public StreamableServerNettySpecification prompts(Map<String, McpServerFeatures.PromptSpecification> prompts) {
Assert.notNull(prompts, "Prompts map must not be null");
this.prompts.putAll(prompts);
return this;
}
public NettySpecification prompts(List<McpServerFeatures.PromptSpecification> prompts) {
public StreamableServerNettySpecification prompts(List<McpServerFeatures.PromptSpecification> prompts) {
Assert.notNull(prompts, "Prompts map must not be null");
for (McpServerFeatures.PromptSpecification prompt : prompts) {
this.prompts.put(prompt.getPrompt().getName(), prompt);
@@ -185,7 +162,7 @@ public interface McpServer {
return this;
}
public NettySpecification prompts(McpServerFeatures.PromptSpecification... prompts) {
public StreamableServerNettySpecification 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);
@@ -193,7 +170,7 @@ public interface McpServer {
return this;
}
public NettySpecification rootsChangeHandler(
public StreamableServerNettySpecification rootsChangeHandler(
BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>> handler) {
Assert.notNull(handler, "Consumer must not be null");
this.rootsChangeHandlers.add(handler);
@@ -201,42 +178,222 @@ public interface McpServer {
}
public NettySpecification rootsChangeHandlers(
public StreamableServerNettySpecification rootsChangeHandlers(
List<BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>> handlers) {
Assert.notNull(handlers, "Handlers list must not be null");
this.rootsChangeHandlers.addAll(handlers);
return this;
}
public NettySpecification rootsChangeHandlers(
public StreamableServerNettySpecification rootsChangeHandlers(
@SuppressWarnings("unchecked") BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>... handlers) {
Assert.notNull(handlers, "Handlers list must not be null");
return this.rootsChangeHandlers(Arrays.asList(handlers));
}
public NettySpecification objectMapper(ObjectMapper objectMapper) {
public StreamableServerNettySpecification objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
public StreamableServerNettySpecification commandExecutor(CommandExecutor commandExecutor) {
Assert.notNull(commandExecutor, "CommandExecutor must not be null");
this.commandExecutor = commandExecutor;
return this;
}
public McpNettyServer build() {
ObjectMapper mapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper();
ObjectMapper mapper = this.objectMapper != null ? this.objectMapper : JsonParser.getObjectMapper();
Assert.notNull(this.commandExecutor, "CommandExecutor must be set before building");
return new McpNettyServer(
this.transportProvider,
this.transportProvider, mapper, this.requestTimeout,
new McpServerFeatures.McpServerConfig(this.serverInfo, this.serverCapabilities, this.tools,
this.resources, this.resourceTemplates, this.prompts, this.rootsChangeHandlers, this.instructions
), this.commandExecutor
);
}
}
class StatelessServerNettySpecification {
private final McpStatelessServerTransport transport;
ObjectMapper objectMapper;
McpSchema.Implementation serverInfo = DEFAULT_SERVER_INFO;
McpSchema.ServerCapabilities serverCapabilities;
String instructions;
CommandExecutor commandExecutor;
final List<McpStatelessServerFeatures.ToolSpecification> tools = new ArrayList<>();
final Map<String, McpStatelessServerFeatures.ResourceSpecification> resources = new HashMap<>();
final List<McpSchema.ResourceTemplate> resourceTemplates = new ArrayList<>();
final Map<String, McpStatelessServerFeatures.PromptSpecification> prompts = new HashMap<>();
final List<BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>> rootsChangeHandlers = new ArrayList<>();
Duration requestTimeout = Duration.ofSeconds(10); // Default timeout
StatelessServerNettySpecification(McpStatelessServerTransport transport) {
this.transport = transport;
}
public StatelessServerNettySpecification serverInfo(McpSchema.Implementation serverInfo) {
Assert.notNull(serverInfo, "Server info must not be null");
this.serverInfo = serverInfo;
return this;
}
public StatelessServerNettySpecification requestTimeout(Duration requestTimeout) {
Assert.notNull(requestTimeout, "Request timeout must not be null");
this.requestTimeout = requestTimeout;
return this;
}
public StatelessServerNettySpecification 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 StatelessServerNettySpecification instructions(String instructions) {
this.instructions = instructions;
return this;
}
public StatelessServerNettySpecification capabilities(McpSchema.ServerCapabilities serverCapabilities) {
this.serverCapabilities = serverCapabilities;
return this;
}
public StatelessServerNettySpecification tools(List<McpStatelessServerFeatures.ToolSpecification> toolRegistrations) {
Assert.notNull(toolRegistrations, "Tool handlers list must not be null");
this.tools.addAll(toolRegistrations);
return this;
}
public StatelessServerNettySpecification tools(McpStatelessServerFeatures.ToolSpecification... toolRegistrations) {
for (McpStatelessServerFeatures.ToolSpecification tool : toolRegistrations) {
this.tools.add(tool);
}
return this;
}
public StatelessServerNettySpecification resources(Map<String, McpStatelessServerFeatures.ResourceSpecification> resourceSpecifications) {
Assert.notNull(resourceSpecifications, "Resource handlers map must not be null");
this.resources.putAll(resourceSpecifications);
return this;
}
public StatelessServerNettySpecification resources(List<McpStatelessServerFeatures.ResourceSpecification> resourceSpecifications) {
Assert.notNull(resourceSpecifications, "Resource handlers list must not be null");
for (McpStatelessServerFeatures.ResourceSpecification resource : resourceSpecifications) {
this.resources.put(resource.getResource().getUri(), resource);
}
return this;
}
public StatelessServerNettySpecification resources(McpStatelessServerFeatures.ResourceSpecification... resourceSpecifications) {
Assert.notNull(resourceSpecifications, "Resource handlers list must not be null");
for (McpStatelessServerFeatures.ResourceSpecification resource : resourceSpecifications) {
this.resources.put(resource.getResource().getUri(), resource);
}
return this;
}
public StatelessServerNettySpecification resourceTemplates(List<McpSchema.ResourceTemplate> resourceTemplates) {
Assert.notNull(resourceTemplates, "Resource templates must not be null");
this.resourceTemplates.addAll(resourceTemplates);
return this;
}
public StatelessServerNettySpecification resourceTemplates(McpSchema.ResourceTemplate... resourceTemplates) {
Assert.notNull(resourceTemplates, "Resource templates must not be null");
this.resourceTemplates.addAll(Arrays.asList(resourceTemplates));
return this;
}
public StatelessServerNettySpecification prompts(Map<String, McpStatelessServerFeatures.PromptSpecification> prompts) {
Assert.notNull(prompts, "Prompts map must not be null");
this.prompts.putAll(prompts);
return this;
}
public StatelessServerNettySpecification prompts(List<McpStatelessServerFeatures.PromptSpecification> prompts) {
Assert.notNull(prompts, "Prompts map must not be null");
for (McpStatelessServerFeatures.PromptSpecification prompt : prompts) {
this.prompts.put(prompt.getPrompt().getName(), prompt);
}
return this;
}
public StatelessServerNettySpecification prompts(McpStatelessServerFeatures.PromptSpecification... prompts) {
Assert.notNull(prompts, "Prompts map must not be null");
for (McpStatelessServerFeatures.PromptSpecification prompt : prompts) {
this.prompts.put(prompt.getPrompt().getName(), prompt);
}
return this;
}
public StatelessServerNettySpecification rootsChangeHandler(
BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>> handler) {
Assert.notNull(handler, "Consumer must not be null");
this.rootsChangeHandlers.add(handler);
return this;
}
public StatelessServerNettySpecification rootsChangeHandlers(
List<BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>> handlers) {
Assert.notNull(handlers, "Handlers list must not be null");
this.rootsChangeHandlers.addAll(handlers);
return this;
}
public StatelessServerNettySpecification rootsChangeHandlers(
@SuppressWarnings("unchecked") BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>... handlers) {
Assert.notNull(handlers, "Handlers list must not be null");
return this.rootsChangeHandlers(Arrays.asList(handlers));
}
public StatelessServerNettySpecification objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
public StatelessServerNettySpecification commandExecutor(CommandExecutor commandExecutor) {
Assert.notNull(commandExecutor, "CommandExecutor must not be null");
this.commandExecutor = commandExecutor;
return this;
}
public McpStatelessNettyServer build() {
ObjectMapper mapper = this.objectMapper != null ? this.objectMapper : JsonParser.getObjectMapper();
return new McpStatelessNettyServer(
this.transport,
mapper,
this.requestTimeout,
new McpServerFeatures.McpServerConfig(
new McpStatelessServerFeatures.McpServerConfig(
this.serverInfo,
this.serverCapabilities,
this.tools,
this.resources,
this.resourceTemplates,
this.prompts,
this.rootsChangeHandlers,
this.instructions
)
),
this.commandExecutor
);
}
}
}
@@ -1,14 +1,15 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
import com.taobao.arthas.mcp.server.protocol.spec.*;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
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.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.BiFunction;
@@ -159,11 +160,11 @@ public class McpServerFeatures {
public static class ToolSpecification {
private final McpSchema.Tool tool;
private final BiFunction<McpNettyServerExchange, Map<String, Object>, CompletableFuture<McpSchema.CallToolResult>> call;
private final ToolCallFunction call;
public ToolSpecification(
McpSchema.Tool tool,
BiFunction<McpNettyServerExchange, Map<String, Object>, CompletableFuture<McpSchema.CallToolResult>> call) {
ToolCallFunction call) {
this.tool = tool;
this.call = call;
}
@@ -172,11 +173,23 @@ public class McpServerFeatures {
return tool;
}
public BiFunction<McpNettyServerExchange, Map<String, Object>, CompletableFuture<McpSchema.CallToolResult>> getCall() {
public ToolCallFunction getCall() {
return call;
}
}
/**
* Tool call function interface with three parameters
*/
@FunctionalInterface
public interface ToolCallFunction {
CompletableFuture<McpSchema.CallToolResult> apply(
McpNettyServerExchange exchange,
ArthasCommandContext commandContext,
McpSchema.CallToolRequest arguments
);
}
public static class ResourceSpecification {
private final McpSchema.Resource resource;
private final BiFunction<McpNettyServerExchange, McpSchema.ReadResourceRequest, CompletableFuture<McpSchema.ReadResourceResult>> readHandler;
@@ -0,0 +1,456 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
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.CommandExecutor;
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.McpStatelessServerTransport;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;
/**
* A Netty-based MCP server implementation that provides access to tools, resources, and prompts.
*
* @author Yeaury
*/
public class McpStatelessNettyServer {
private static final Logger logger = LoggerFactory.getLogger(McpStatelessNettyServer.class);
private final McpStatelessServerTransport mcpTransportProvider;
private final ObjectMapper objectMapper;
private final McpSchema.ServerCapabilities serverCapabilities;
private final McpSchema.Implementation serverInfo;
private final String instructions;
private final CopyOnWriteArrayList<McpStatelessServerFeatures.ToolSpecification> tools = new CopyOnWriteArrayList<>();
private final CopyOnWriteArrayList<McpSchema.ResourceTemplate> resourceTemplates = new CopyOnWriteArrayList<>();
private final ConcurrentHashMap<String, McpStatelessServerFeatures.ResourceSpecification> resources = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, McpStatelessServerFeatures.PromptSpecification> prompts = new ConcurrentHashMap<>();
private List<String> protocolVersions = Collections.singletonList(McpSchema.LATEST_PROTOCOL_VERSION);
public McpStatelessNettyServer(
McpStatelessServerTransport mcpTransport,
ObjectMapper objectMapper,
Duration requestTimeout,
McpStatelessServerFeatures.McpServerConfig features,
CommandExecutor commandExecutor) {
this.mcpTransportProvider = mcpTransport;
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<String, McpStatelessRequestHandler<?>> 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, commandContext, 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());
}
McpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(requestHandlers, new HashMap<>(), commandExecutor);
mcpTransport.setMcpHandler(handler);
}
// ---------------------------------------
// Lifecycle Management
// ---------------------------------------
private CompletableFuture<McpSchema.InitializeResult> 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<Void> closeGracefully() {
return this.mcpTransportProvider.closeGracefully();
}
public void close() {
this.mcpTransportProvider.close();
}
private McpNotificationHandler rootsListChangedNotificationHandler(
List<BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>>> rootsChangeConsumers) {
return (exchange, commandContext, params) -> {
CompletableFuture<McpSchema.ListRootsResult> futureRoots = exchange.listRoots();
return futureRoots.thenCompose(listRootsResult -> {
List<McpSchema.Root> roots = listRootsResult.getRoots();
List<CompletableFuture<?>> futures = new ArrayList<>();
for (BiFunction<McpNettyServerExchange, List<McpSchema.Root>, CompletableFuture<Void>> consumer : rootsChangeConsumers) {
CompletableFuture<Void> 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<Void> addTool(McpStatelessServerFeatures.ToolSpecification toolSpecification) {
if (toolSpecification == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Tool specification must not be null"));
return future;
}
if (toolSpecification.getTool() == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Tool must not be null"));
return future;
}
if (toolSpecification.getCall() == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Tool call handler must not be null"));
return future;
}
if (this.serverCapabilities.getTools() == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Server must be configured with tool capabilities"));
return future;
}
return CompletableFuture
.runAsync(() -> {
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());
})
.exceptionally(ex -> {
Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex;
logger.error("Error while adding tool", cause);
throw new CompletionException(cause);
});
}
public CompletableFuture<Void> removeTool(String toolName) {
if (toolName == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Tool name must not be null"));
return future;
}
if (this.serverCapabilities.getTools() == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Server must be configured with tool capabilities"));
return future;
}
return CompletableFuture
.runAsync(() -> {
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);
})
.exceptionally(ex -> {
Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex;
logger.error("Error while removing tool '{}'", toolName, cause);
throw new CompletionException(cause);
});
}
private McpStatelessRequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
return (exchange, commandContext, params) -> {
List<McpSchema.Tool> tools = new ArrayList<>();
for (McpStatelessServerFeatures.ToolSpecification toolSpec : this.tools) {
tools.add(toolSpec.getTool());
}
return CompletableFuture.completedFuture(new McpSchema.ListToolsResult(tools, null));
};
}
private McpStatelessRequestHandler<McpSchema.CallToolResult> toolsCallRequestHandler() {
return (context, commandContext, params) -> {
McpSchema.CallToolRequest callToolRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.CallToolRequest>() {
});
Optional<McpStatelessServerFeatures.ToolSpecification> toolSpecification = this.tools.stream()
.filter(tr -> callToolRequest.getName().equals(tr.getTool().getName()))
.findAny();
if (!toolSpecification.isPresent()) {
CompletableFuture<McpSchema.CallToolResult> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("no tool found: " + callToolRequest.getName()));
return future;
}
return toolSpecification.get().getCall().apply(context, commandContext, callToolRequest.getArguments());
};
}
// ---------------------------------------
// Resource Management
// ---------------------------------------
public CompletableFuture<Void> addResource(McpStatelessServerFeatures.ResourceSpecification resourceSpecification) {
if (resourceSpecification == null || resourceSpecification.getResource() == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Resource must not be null"));
return future;
}
if (this.serverCapabilities.getResources() == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Server must be configured with resource capabilities"));
return future;
}
return CompletableFuture
.runAsync(() -> {
String uri = resourceSpecification.getResource().getUri();
if (this.resources.putIfAbsent(uri, resourceSpecification) != null) {
throw new CompletionException(new McpError("Resource with URI '" + uri + "' already exists"));
}
logger.debug("Added resource handler: {}", uri);
})
.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<Void> removeResource(String resourceUri) {
if (resourceUri == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Resource URI must not be null"));
return future;
}
if (this.serverCapabilities.getResources() == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Server must be configured with resource capabilities"));
return future;
}
return CompletableFuture
.runAsync(() -> {
McpStatelessServerFeatures.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);
})
.exceptionally(ex -> {
Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex;
logger.error("Error while removing resource '{}'", resourceUri, cause);
throw new CompletionException(cause);
});
}
private McpStatelessRequestHandler<McpSchema.ListResourcesResult> resourcesListRequestHandler() {
return (exchange, commandContext, params) -> {
List<McpSchema.Resource> resourceList = new ArrayList<>();
for (McpStatelessServerFeatures.ResourceSpecification spec : this.resources.values()) {
resourceList.add(spec.getResource());
}
return CompletableFuture.completedFuture(new McpSchema.ListResourcesResult(resourceList, null));
};
}
private McpStatelessRequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplateListRequestHandler() {
return (context, commandContext, params) -> CompletableFuture
.completedFuture(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null));
}
private McpStatelessRequestHandler<McpSchema.ReadResourceResult> resourcesReadRequestHandler() {
return (context, commandContext, params) -> {
McpSchema.ReadResourceRequest resourceRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.ReadResourceRequest>() {
});
String resourceUri = resourceRequest.getUri();
McpStatelessServerFeatures.ResourceSpecification specification = this.resources.get(resourceUri);
if (specification != null) {
return specification.getReadHandler().apply(context, resourceRequest);
}
CompletableFuture<McpSchema.ReadResourceResult> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Resource not found: " + resourceUri));
return future;
};
}
// ---------------------------------------
// Prompt Management
// ---------------------------------------
public CompletableFuture<Void> addPrompt(McpStatelessServerFeatures.PromptSpecification promptSpecification) {
if (promptSpecification == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Prompt specification must not be null"));
return future;
}
if (this.serverCapabilities.getPrompts() == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Server must be configured with prompt capabilities"));
return future;
}
return CompletableFuture
.runAsync(() -> {
String name = promptSpecification.getPrompt().getName();
McpStatelessServerFeatures.PromptSpecification existing =
this.prompts.putIfAbsent(name, promptSpecification);
if (existing != null) {
throw new CompletionException(new McpError("Prompt with name '" + name + "' already exists"));
}
logger.debug("Added prompt handler: {}", name);
})
.exceptionally(ex -> {
Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex;
String name = promptSpecification.getPrompt().getName();
logger.error("Error while adding prompt '{}'", name, cause);
throw new CompletionException(cause);
});
}
public CompletableFuture<Void> removePrompt(String promptName) {
if (promptName == null || promptName.isEmpty()) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Prompt name must not be null or empty"));
return future;
}
if (this.serverCapabilities.getPrompts() == null) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Server must be configured with prompt capabilities"));
return future;
}
return CompletableFuture
.runAsync(() -> {
McpStatelessServerFeatures.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);
})
.exceptionally(ex -> {
Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex;
logger.error("Error while removing prompt '{}'", promptName, cause);
throw new CompletionException(cause);
});
}
private McpStatelessRequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler() {
return (exchange, commandContext, params) -> {
List<McpSchema.Prompt> promptList = new ArrayList<>();
for (McpStatelessServerFeatures.PromptSpecification promptSpec : this.prompts.values()) {
promptList.add(promptSpec.getPrompt());
}
return CompletableFuture.completedFuture(new McpSchema.ListPromptsResult(promptList, null));
};
}
private McpStatelessRequestHandler<McpSchema.GetPromptResult> promptsGetRequestHandler() {
return (context, commandContext, params) -> {
McpSchema.GetPromptRequest promptRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.GetPromptRequest>() {
});
McpStatelessServerFeatures.PromptSpecification specification = this.prompts.get(promptRequest.getName());
if (specification != null) {
return specification.getPromptHandler().apply(context, promptRequest);
}
CompletableFuture<McpSchema.GetPromptResult> future = new CompletableFuture<>();
future.completeExceptionally(new McpError("Prompt not found: " + promptRequest.getName()));
return future;
};
}
// ---------------------------------------
// Sampling
// ---------------------------------------
public void setProtocolVersions(List<String> protocolVersions) {
this.protocolVersions = protocolVersions;
}
}
@@ -0,0 +1,23 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
import java.util.concurrent.CompletableFuture;
/**
* Handler for MCP notifications in a stateless server.
*/
public interface McpStatelessNotificationHandler {
/**
* Handle to notification and complete once done.
* @param transportContext {@link McpTransportContext} associated with the transport
* @param params the payload of the MCP notification
* @return Mono which completes once the processing is done
*/
CompletableFuture<Void> handle(McpTransportContext transportContext, Object params);
}
@@ -0,0 +1,24 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import java.util.concurrent.CompletableFuture;
/**
* Handler for MCP requests in a stateless server.
*/
public interface McpStatelessRequestHandler<R> {
/**
* Handle the request and complete with a result.
* @param transportContext {@link McpTransportContext} associated with the transport
* @param params the payload of the MCP request
* @return Mono which completes with the response object
*/
CompletableFuture<R> handle(McpTransportContext transportContext, ArthasCommandContext arthasCommandContext, Object params);
}
@@ -0,0 +1,218 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.util.Assert;
import com.taobao.arthas.mcp.server.util.Utils;
import java.util.*;
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 McpStatelessServerFeatures {
public static class McpServerConfig {
private final McpSchema.Implementation serverInfo;
private final McpSchema.ServerCapabilities serverCapabilities;
private final List<ToolSpecification> tools;
private final Map<String, ResourceSpecification> resources;
private final List<McpSchema.ResourceTemplate> resourceTemplates;
private final Map<String, PromptSpecification> prompts;
private final String instructions;
public McpServerConfig(
McpSchema.Implementation serverInfo,
McpSchema.ServerCapabilities serverCapabilities,
List<ToolSpecification> tools,
Map<String, ResourceSpecification> resources,
List<McpSchema.ResourceTemplate> resourceTemplates,
Map<String, PromptSpecification> prompts,
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.serverInfo = serverInfo;
this.serverCapabilities = serverCapabilities;
this.instructions = instructions;
}
public McpSchema.Implementation getServerInfo() {
return serverInfo;
}
public McpSchema.ServerCapabilities getServerCapabilities() {
return serverCapabilities;
}
public List<ToolSpecification> getTools() {
return tools;
}
public Map<String, ResourceSpecification> getResources() {
return resources;
}
public List<McpSchema.ResourceTemplate> getResourceTemplates() {
return resourceTemplates;
}
public Map<String, PromptSpecification> getPrompts() {
return prompts;
}
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<ToolSpecification> tools = new ArrayList<>();
private final Map<String, ResourceSpecification> resources = new HashMap<>();
private final List<McpSchema.ResourceTemplate> resourceTemplates = new ArrayList<>();
private final Map<String, PromptSpecification> prompts = new HashMap<>();
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 instructions(String instructions) {
this.instructions = instructions;
return this;
}
public McpServerConfig build() {
return new McpServerConfig(serverInfo, serverCapabilities, tools, resources, resourceTemplates, prompts,
instructions);
}
}
}
public static class ToolSpecification {
private final McpSchema.Tool tool;
private final ToolCallFunction call;
public ToolSpecification(
McpSchema.Tool tool,
ToolCallFunction call) {
this.tool = tool;
this.call = call;
}
public McpSchema.Tool getTool() {
return tool;
}
public ToolCallFunction getCall() {
return call;
}
}
/**
* Tool call function interface with three parameters
*/
@FunctionalInterface
public interface ToolCallFunction {
CompletableFuture<McpSchema.CallToolResult> apply(
McpTransportContext context,
ArthasCommandContext commandContext,
Map<String, Object> arguments
);
}
public static class ResourceSpecification {
private final McpSchema.Resource resource;
private final BiFunction<McpTransportContext, McpSchema.ReadResourceRequest, CompletableFuture<McpSchema.ReadResourceResult>> readHandler;
public ResourceSpecification(
McpSchema.Resource resource,
BiFunction<McpTransportContext, McpSchema.ReadResourceRequest, CompletableFuture<McpSchema.ReadResourceResult>> readHandler) {
this.resource = resource;
this.readHandler = readHandler;
}
public McpSchema.Resource getResource() {
return resource;
}
public BiFunction<McpTransportContext, McpSchema.ReadResourceRequest, CompletableFuture<McpSchema.ReadResourceResult>> getReadHandler() {
return readHandler;
}
}
public static class PromptSpecification {
private final McpSchema.Prompt prompt;
private final BiFunction<McpTransportContext, McpSchema.GetPromptRequest, CompletableFuture<McpSchema.GetPromptResult>> promptHandler;
public PromptSpecification(
McpSchema.Prompt prompt,
BiFunction<McpTransportContext, McpSchema.GetPromptRequest, CompletableFuture<McpSchema.GetPromptResult>> promptHandler) {
this.prompt = prompt;
this.promptHandler = promptHandler;
}
public McpSchema.Prompt getPrompt() {
return prompt;
}
public BiFunction<McpTransportContext, McpSchema.GetPromptRequest, CompletableFuture<McpSchema.GetPromptResult>> getPromptHandler() {
return promptHandler;
}
}
}
@@ -0,0 +1,32 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import java.util.concurrent.CompletableFuture;
public interface McpStatelessServerHandler {
/**
* Handle the request using user-provided feature implementations.
* @param transportContext {@link McpTransportContext} carrying transport layer
* metadata
* @param request the request JSON object
* @return Mono containing the JSON response
*/
CompletableFuture<McpSchema.JSONRPCResponse> handleRequest(McpTransportContext transportContext,
McpSchema.JSONRPCRequest request);
/**
* Handle the notification.
* @param transportContext {@link McpTransportContext} carrying transport layer
* metadata
* @param notification the notification JSON object
* @return Mono that completes once handling is finished
*/
CompletableFuture<Void> handleNotification(McpTransportContext transportContext, McpSchema.JSONRPCNotification notification);
}
@@ -0,0 +1,19 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
public interface McpTransportContext {
String KEY = "MCP_TRANSPORT_CONTEXT";
McpTransportContext EMPTY = new DefaultMcpTransportContext();
Object get(String key);
void put(String key, Object value);
McpTransportContext copy();
}
@@ -0,0 +1,25 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server;
/**
* Interface for extracting transport context from server requests.
* This allows inspection of HTTP transport level metadata during request processing.
*
* @param <T> the type of server request
*/
@FunctionalInterface
public interface McpTransportContextExtractor<T> {
/**
* Extract transport context from the server request.
*
* @param serverRequest the server request to extract context from
* @param context the base context to fill in
* @return the updated context with extracted information
*/
McpTransportContext extract(T serverRequest, McpTransportContext context);
}
@@ -0,0 +1,324 @@
package com.taobao.arthas.mcp.server.protocol.server.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.taobao.arthas.mcp.server.protocol.server.McpTransportContextExtractor;
import com.taobao.arthas.mcp.server.protocol.spec.McpError;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import com.taobao.arthas.mcp.server.tool.ToolCallback;
import com.taobao.arthas.mcp.server.util.Assert;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import static com.taobao.arthas.mcp.server.protocol.spec.McpSchema.*;
/**
* MCP HTTP请求处理器,分发请求到无状态或流式处理器。
*
* @author Yeaury
*/
public class McpHttpRequestHandler {
private static final Logger logger = LoggerFactory.getLogger(McpHttpRequestHandler.class);
public static final String APPLICATION_JSON = "application/json";
public static final String TEXT_EVENT_STREAM = "text/event-stream";
private static final String ACCEPT_HEADER = "Accept";
private final String mcpEndpoint;
private final ObjectMapper objectMapper;
private final McpTransportContextExtractor<FullHttpRequest> contextExtractor;
private final AtomicBoolean isClosing = new AtomicBoolean(false);
private McpStatelessHttpRequestHandler statelessHandler;
private McpStreamableHttpRequestHandler streamableHandler;
private final Map<String, Boolean> toolStreamableCache = new ConcurrentHashMap<>();
public McpHttpRequestHandler(String mcpEndpoint, ObjectMapper objectMapper,
McpTransportContextExtractor<FullHttpRequest> contextExtractor,
List<ToolCallback> tools) {
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
Assert.notNull(objectMapper, "objectMapper must not be null");
Assert.notNull(contextExtractor, "contextExtractor must not be null");
this.mcpEndpoint = mcpEndpoint;
this.objectMapper = objectMapper;
this.contextExtractor = contextExtractor;
registerStreamableTools(tools);
}
public void setStatelessHandler(McpStatelessHttpRequestHandler statelessHandler) {
this.statelessHandler = statelessHandler;
}
public void setStreamableHandler(McpStreamableHttpRequestHandler streamableHandler) {
this.streamableHandler = streamableHandler;
}
public void handle(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
String uri = request.uri();
if (!uri.endsWith(mcpEndpoint)) {
sendError(ctx, HttpResponseStatus.NOT_FOUND, new McpError("Endpoint not found"));
return;
}
if (isClosing.get()) {
sendError(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE, new McpError("Server is shutting down"));
return;
}
boolean useStreamable = shouldUseStreamableTransport(request);
logger.debug("Request {} {} -> using {} transport",
request.method(), request.uri(), useStreamable ? "streamable" : "stateless");
try {
if (useStreamable) {
if (streamableHandler == null) {
sendError(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE,
new McpError("Streamable transport handler not available"));
return;
}
streamableHandler.handle(ctx, request);
} else {
if (statelessHandler == null) {
sendError(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE,
new McpError("Stateless transport handler not available"));
return;
}
statelessHandler.handle(ctx, request);
}
} catch (Exception e) {
logger.error("Error handling request: {}", e.getMessage(), e);
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Error processing request: " + e.getMessage()));
}
}
private boolean shouldUseStreamableTransport(FullHttpRequest request) {
String accept = request.headers().get(ACCEPT_HEADER);
boolean supportsSSE = (accept != null && accept.contains(TEXT_EVENT_STREAM));
HttpMethod method = request.method();
if (!supportsSSE) {
logger.debug("Using stateless transport for {} without SSE support", method);
return false;
}
if (method == HttpMethod.GET || method == HttpMethod.DELETE) {
logger.debug("Using streamable transport for {} with SSE support", method);
return true;
}
if (method == HttpMethod.POST) {
try {
ByteBuf content = request.content();
if (content.readableBytes() > 0) {
String body = content.toString(CharsetUtil.UTF_8);
content.resetReaderIndex();
McpSchema.JSONRPCMessage message =
McpSchema.deserializeJsonRpcMessage(objectMapper, body);
return isStreamingRequest(message);
}
} catch (Exception e) {
logger.debug("Failed to parse POST body, defaulting to stateless: {}", e.getMessage());
}
}
logger.debug("Using stateless transport as default");
return false;
}
/**
* 判断是否为流式工具
*/
private boolean isStreamingRequest(McpSchema.JSONRPCMessage message) {
if (message instanceof McpSchema.JSONRPCRequest) {
McpSchema.JSONRPCRequest request = (McpSchema.JSONRPCRequest) message;
String method = request.getMethod();
if (METHOD_INITIALIZE.equals(method)) {
logger.debug("Initialize method is streamable");
return true;
}
if (METHOD_TOOLS_LIST.equals(method)) {
logger.debug("tools/list method is not streamable");
return false;
}
if (METHOD_TOOLS_CALL.equals(method)) {
return isStreamableToolCall(request);
}
}
if (message instanceof McpSchema.JSONRPCNotification ||
message instanceof McpSchema.JSONRPCResponse) {
logger.debug("Notification/response message is streamable");
return true;
}
return false;
}
private boolean isStreamableToolCall(McpSchema.JSONRPCRequest request) {
Object params = request.getParams();
if (!(params instanceof java.util.Map)) {
return false;
}
@SuppressWarnings("unchecked")
java.util.Map<String, Object> paramMap = (java.util.Map<String, Object>) params;
String toolName = (String) paramMap.get("name");
if (toolName == null) {
return false;
}
boolean isStreamable = isStreamableTool(toolName);
logger.debug("Tool {} is {} (from cache)", toolName, isStreamable ? "streamable" : "non-streamable");
return isStreamable;
}
public void registerStreamableTools(List<ToolCallback> tools) {
if (tools == null || tools.isEmpty()) {
return;
}
for (ToolCallback tool : tools) {
if (tool != null && tool.getToolDefinition() != null) {
String toolName = tool.getToolDefinition().getName();
boolean isStreamable = tool.getToolDefinition().isStreamable();
toolStreamableCache.put(toolName, isStreamable);
logger.debug("Registered tool: {} (streamable: {})", toolName, isStreamable);
}
}
logger.info("Registered {} tools to cache", toolStreamableCache.size());
}
private boolean isStreamableTool(String toolName) {
if (toolName == null) {
return false;
}
Boolean isStreamable = toolStreamableCache.get(toolName);
if (isStreamable != null) {
return isStreamable;
}
logger.warn("Tool '{}' not found in cache, assuming non-streamable", toolName);
return false;
}
public CompletableFuture<Void> closeGracefully() {
return CompletableFuture.runAsync(() -> {
this.isClosing.set(true);
logger.debug("Initiating graceful shutdown of MCP handler");
CompletableFuture<Void> statelessClose = CompletableFuture.completedFuture(null);
CompletableFuture<Void> streamableClose = CompletableFuture.completedFuture(null);
if (statelessHandler != null) {
statelessClose = statelessHandler.closeGracefully();
}
if (streamableHandler != null) {
streamableClose = streamableHandler.closeGracefully();
}
CompletableFuture.allOf(statelessClose, streamableClose).join();
logger.debug("Graceful shutdown completed");
});
}
private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status, McpError mcpError) {
try {
String jsonError = objectMapper.writeValueAsString(mcpError);
ByteBuf content = Unpooled.copiedBuffer(jsonError, 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, response.content().readableBytes());
response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
ctx.writeAndFlush(response);
} catch (Exception e) {
logger.error("Failed to send error response: {}", e.getMessage());
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.INTERNAL_SERVER_ERROR
);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
ctx.writeAndFlush(response);
}
}
public String getMcpEndpoint() {
return mcpEndpoint;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String mcpEndpoint = "/mcp";
private ObjectMapper objectMapper;
private McpTransportContextExtractor<FullHttpRequest> contextExtractor = (request, context) -> context;
private List<ToolCallback> tools;
public Builder mcpEndpoint(String mcpEndpoint) {
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
this.mcpEndpoint = mcpEndpoint;
return this;
}
public Builder objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
public Builder contextExtractor(McpTransportContextExtractor<FullHttpRequest> contextExtractor) {
Assert.notNull(contextExtractor, "Context extractor must not be null");
this.contextExtractor = contextExtractor;
return this;
}
public Builder tools(List<ToolCallback> tools) {
Assert.notNull(tools, "Tools must not be null");
this.tools = tools;
return this;
}
public McpHttpRequestHandler build() {
Assert.notNull(this.objectMapper, "ObjectMapper must be set");
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new McpHttpRequestHandler(this.mcpEndpoint, this.objectMapper, this.contextExtractor, tools);
}
}
}
@@ -1,379 +0,0 @@
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<String, McpServerSession> sessions = new ConcurrentHashMap<>();
/** SSE connection mapping: session ID -> Channel */
private final Map<String, Channel> 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<Void> notifyClients(String method, Object params) {
CompletableFuture<Void> 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<Void> closeGracefully() {
CompletableFuture<Void> 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<Void> sendMessage(McpSchema.JSONRPCMessage message) {
CompletableFuture<Void> 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> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
return objectMapper.convertValue(data, typeRef);
}
@Override
public CompletableFuture<Void> closeGracefully() {
CompletableFuture<Void> 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();
}
}
}
}
@@ -0,0 +1,227 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.taobao.arthas.mcp.server.protocol.server.DefaultMcpTransportContext;
import com.taobao.arthas.mcp.server.protocol.server.McpStatelessServerHandler;
import com.taobao.arthas.mcp.server.protocol.server.McpTransportContext;
import com.taobao.arthas.mcp.server.protocol.server.McpTransportContextExtractor;
import com.taobao.arthas.mcp.server.protocol.spec.McpError;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import com.taobao.arthas.mcp.server.util.Assert;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Server-side HTTP request handler for stateless MCP transport.
* This handler processes HTTP requests without maintaining client sessions.
*
*/
public class McpStatelessHttpRequestHandler {
private static final Logger logger = LoggerFactory.getLogger(McpStatelessHttpRequestHandler.class);
public static final String UTF_8 = "UTF-8";
public static final String APPLICATION_JSON = "application/json";
public static final String TEXT_EVENT_STREAM = "text/event-stream";
public static final String ACCEPT = "Accept";
private static final String FAILED_TO_SEND_ERROR_RESPONSE = "Failed to send error response: {}";
private final ObjectMapper objectMapper;
private final String mcpEndpoint;
private McpStatelessServerHandler mcpHandler;
private final McpTransportContextExtractor<FullHttpRequest> contextExtractor;
private final AtomicBoolean isClosing = new AtomicBoolean(false);
/**
* Constructs a new McpStatelessHttpRequestHandler instance.
*
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
* @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC messages
* @param contextExtractor The extractor for transport context from the request
*/
public McpStatelessHttpRequestHandler(ObjectMapper objectMapper, String mcpEndpoint,
McpTransportContextExtractor<FullHttpRequest> contextExtractor) {
Assert.notNull(objectMapper, "objectMapper must not be null");
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
Assert.notNull(contextExtractor, "contextExtractor must not be null");
this.objectMapper = objectMapper;
this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor;
}
public void setMcpHandler(McpStatelessServerHandler mcpHandler) {
this.mcpHandler = mcpHandler;
}
/**
* Initiates a graceful shutdown of the handler.
*
* @return A CompletableFuture that completes when shutdown is initiated
*/
public CompletableFuture<Void> closeGracefully() {
return CompletableFuture.supplyAsync(() -> {
this.isClosing.set(true);
return null;
});
}
protected void handle(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
String uri = request.uri();
if (!uri.endsWith(mcpEndpoint)) {
sendError(ctx, HttpResponseStatus.NOT_FOUND, new McpError("Endpoint not found"));
return;
}
if (isClosing.get()) {
sendError(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE, new McpError("Server is shutting down"));
return;
}
HttpMethod method = request.method();
if (method == HttpMethod.GET) {
sendError(ctx, HttpResponseStatus.METHOD_NOT_ALLOWED, new McpError("GET method not allowed for stateless transport"));
} else if (method == HttpMethod.POST) {
handlePostRequest(ctx, request);
} else {
sendError(ctx, HttpResponseStatus.METHOD_NOT_ALLOWED, new McpError("Only POST method is supported"));
}
}
/**
* Handles POST requests for incoming JSON-RPC messages from clients.
*/
private void handlePostRequest(ChannelHandlerContext ctx, FullHttpRequest request) {
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
String accept = request.headers().get(ACCEPT);
if (accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM))) {
sendError(ctx, HttpResponseStatus.BAD_REQUEST,
new McpError("Both application/json and text/event-stream required in Accept header"));
return;
}
try {
ByteBuf content = request.content();
String body = content.toString(CharsetUtil.UTF_8);
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
if (message instanceof McpSchema.JSONRPCRequest) {
McpSchema.JSONRPCRequest jsonrpcRequest = (McpSchema.JSONRPCRequest) message;
try {
this.mcpHandler.handleRequest(transportContext, jsonrpcRequest)
.thenAccept(jsonrpcResponse -> {
try {
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.OK,
Unpooled.copiedBuffer(objectMapper.writeValueAsString(jsonrpcResponse), CharsetUtil.UTF_8)
);
response.headers().set(HttpHeaderNames.CONTENT_TYPE, APPLICATION_JSON);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
} catch (Exception e) {
logger.error("Failed to serialize response: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Failed to serialize response: " + e.getMessage()));
}
})
.exceptionally(e -> {
logger.error("Failed to handle request: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Failed to handle request: " + e.getMessage()));
return null;
});
} catch (Exception e) {
logger.error("Failed to handle request: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Failed to handle request: " + e.getMessage()));
}
} else if (message instanceof McpSchema.JSONRPCNotification) {
McpSchema.JSONRPCNotification jsonrpcNotification = (McpSchema.JSONRPCNotification) message;
try {
this.mcpHandler.handleNotification(transportContext, jsonrpcNotification)
.thenRun(() -> {
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.ACCEPTED
);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
})
.exceptionally(e -> {
logger.error("Failed to handle notification: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Failed to handle notification: " + e.getMessage()));
return null;
});
} catch (Exception e) {
logger.error("Failed to handle notification: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Failed to handle notification: " + e.getMessage()));
}
} else {
sendError(ctx, HttpResponseStatus.BAD_REQUEST,
new McpError("The server accepts either requests or notifications"));
}
} catch (IllegalArgumentException | IOException e) {
logger.error("Failed to deserialize message: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.BAD_REQUEST, new McpError("Invalid message format"));
} catch (Exception e) {
logger.error("Unexpected error handling message: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Unexpected error: " + e.getMessage()));
}
}
/**
* Sends an error response to the client.
*/
private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status, McpError mcpError) {
try {
String jsonError = objectMapper.writeValueAsString(mcpError);
ByteBuf content = Unpooled.copiedBuffer(jsonError, 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, response.content().readableBytes());
response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
} catch (Exception e) {
logger.error(FAILED_TO_SEND_ERROR_RESPONSE, e.getMessage());
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.INTERNAL_SERVER_ERROR
);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
}
}
@@ -0,0 +1,685 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
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.server.DefaultMcpTransportContext;
import com.taobao.arthas.mcp.server.protocol.server.McpTransportContext;
import com.taobao.arthas.mcp.server.protocol.server.McpTransportContextExtractor;
import com.taobao.arthas.mcp.server.protocol.spec.HttpHeaders;
import com.taobao.arthas.mcp.server.protocol.spec.*;
import com.taobao.arthas.mcp.server.util.Assert;
import com.taobao.arthas.mcp.server.util.KeepAliveScheduler;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
/**
* Server-side implementation of the Model Context Protocol (MCP) streamable transport
* layer using HTTP with Server-Sent Events (SSE) through Netty. This implementation
* provides a bridge between Netty operations and the MCP transport interface.
*
* @see McpStreamableServerTransportProvider
*/
public class McpStreamableHttpRequestHandler {
private static final Logger logger = LoggerFactory.getLogger(McpStreamableHttpRequestHandler.class);
/**
* Event type for JSON-RPC messages sent through the SSE connection.
*/
public static final String MESSAGE_EVENT_TYPE = "message";
/**
* Header name for the response media types accepted by the requester.
*/
private static final String ACCEPT = "Accept";
public static final String UTF_8 = "UTF-8";
public static final String APPLICATION_JSON = "application/json";
public static final String TEXT_EVENT_STREAM = "text/event-stream";
private static final String FAILED_TO_SEND_ERROR_RESPONSE = "Failed to send error response: {}";
/**
* The endpoint URI where clients should send their JSON-RPC messages. Defaults to
* "/mcp".
*/
private final String mcpEndpoint;
/**
* Flag indicating whether DELETE requests are disallowed on the endpoint.
*/
private final boolean disallowDelete;
private final ObjectMapper objectMapper;
private McpStreamableServerSession.Factory sessionFactory;
/**
* Map of active client sessions, keyed by mcp-session-id.
*/
private final ConcurrentHashMap<String, McpStreamableServerSession> sessions = new ConcurrentHashMap<>();
private McpTransportContextExtractor<FullHttpRequest> contextExtractor;
/**
* Flag indicating if the transport is shutting down.
*/
private final AtomicBoolean isClosing = new AtomicBoolean(false);
/**
* Keep-alive scheduler for managing session pings. Activated if keepAliveInterval is
* set. Disabled by default.
*/
private KeepAliveScheduler keepAliveScheduler;
/**
* Constructs a new NettyStreamableServerTransportProvider instance.
*
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
* of messages.
* @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC
* messages via HTTP.
* @param disallowDelete Whether to disallow DELETE requests on the endpoint.
* @param contextExtractor The extractor for transport context from the request.
* @param keepAliveInterval Interval for keep-alive pings (null to disable)
* @throws IllegalArgumentException if any parameter is null
*/
public McpStreamableHttpRequestHandler(ObjectMapper objectMapper, String mcpEndpoint,
boolean disallowDelete, McpTransportContextExtractor<FullHttpRequest> contextExtractor,
Duration keepAliveInterval) {
this.objectMapper = objectMapper;
this.mcpEndpoint = mcpEndpoint;
this.disallowDelete = disallowDelete;
this.contextExtractor = contextExtractor;
if (keepAliveInterval != null) {
this.keepAliveScheduler = KeepAliveScheduler
.builder(() -> this.isClosing.get() ? Collections.emptyList() : this.sessions.values())
.initialDelay(keepAliveInterval)
.interval(keepAliveInterval)
.build();
this.keepAliveScheduler.start();
logger.debug("Keep-alive scheduler started with interval: {}ms", keepAliveInterval.toMillis());
}
}
public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) {
this.sessionFactory = sessionFactory;
}
public CompletableFuture<Void> notifyClients(String method, Object params) {
if (this.sessions.isEmpty()) {
logger.debug("No active sessions to broadcast message to");
return CompletableFuture.completedFuture(null);
}
logger.debug("Attempting to broadcast message to {} active sessions", this.sessions.size());
return CompletableFuture.runAsync(() -> {
this.sessions.values().parallelStream().forEach(session -> {
try {
session.sendNotification(method, params);
} catch (Exception e) {
logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage());
}
});
});
}
public CompletableFuture<Void> closeGracefully() {
return CompletableFuture.runAsync(() -> {
this.isClosing.set(true);
logger.debug("Initiating graceful shutdown with {} active sessions", this.sessions.size());
this.sessions.values().parallelStream().forEach(session -> {
try {
session.closeGracefully();
} catch (Exception e) {
logger.error("Failed to close session {}: {}", session.getId(), e.getMessage());
}
});
this.sessions.clear();
logger.debug("Graceful shutdown completed");
if (this.keepAliveScheduler != null) {
this.keepAliveScheduler.shutdown();
}
});
}
protected void handle(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
String uri = request.uri();
if (!uri.endsWith(mcpEndpoint)) {
sendError(ctx, HttpResponseStatus.NOT_FOUND, new McpError("Endpoint not found"));
return;
}
if (isClosing.get()) {
sendError(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE, new McpError("Server is shutting down"));
return;
}
HttpMethod method = request.method();
if (method == HttpMethod.GET) {
handleGetRequest(ctx, request);
} else if (method == HttpMethod.POST) {
handlePostRequest(ctx, request);
} else if (method == HttpMethod.DELETE) {
handleDeleteRequest(ctx, request);
} else {
sendError(ctx, HttpResponseStatus.METHOD_NOT_ALLOWED, new McpError("Method not allowed"));
}
}
/**
* Handles GET requests to establish SSE connections and message replay.
*/
private void handleGetRequest(ChannelHandlerContext ctx, FullHttpRequest request) {
List<String> badRequestErrors = new ArrayList<>();
String accept = request.headers().get(ACCEPT);
if (accept == null || !accept.contains(TEXT_EVENT_STREAM)) {
badRequestErrors.add("text/event-stream required in Accept header");
}
String sessionId = request.headers().get(HttpHeaders.MCP_SESSION_ID);
if (sessionId == null || sessionId.trim().isEmpty()) {
badRequestErrors.add("Session ID required in mcp-session-id header");
}
if (!badRequestErrors.isEmpty()) {
String combinedMessage = String.join("; ", badRequestErrors);
sendError(ctx, HttpResponseStatus.BAD_REQUEST, new McpError(combinedMessage));
return;
}
McpStreamableServerSession session = this.sessions.get(sessionId);
if (session == null) {
sendError(ctx, HttpResponseStatus.NOT_FOUND, new McpError("Session not found"));
return;
}
logger.debug("Handling GET request for session: {}", sessionId);
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
try {
// Set up SSE response headers
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, "keep-alive");
response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED);
ctx.writeAndFlush(response);
NettyStreamableMcpSessionTransport sessionTransport = new NettyStreamableMcpSessionTransport(
sessionId, ctx);
// Check if this is a replay request
String lastEventId = request.headers().get(HttpHeaders.LAST_EVENT_ID);
if (lastEventId != null) {
try {
// Replay messages from the last event ID
try {
session.replay(lastEventId).forEach(message -> {
try {
sessionTransport.sendMessage(message).join();
} catch (Exception e) {
logger.error("Failed to replay message: {}", e.getMessage());
ctx.close();
}
});
} catch (Exception e) {
logger.error("Failed to replay messages: {}", e.getMessage());
ctx.close();
}
} catch (Exception e) {
logger.error("Failed to replay messages: {}", e.getMessage());
ctx.close();
}
} else {
// Establish new listening stream
McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session
.listeningStream(sessionTransport);
// Handle channel closure
ctx.channel().closeFuture().addListener(future -> {
logger.debug("SSE connection closed for session: {}", sessionId);
listeningStream.close();
});
}
} catch (Exception e) {
logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR, new McpError("Internal server error"));
}
}
/**
* Handles POST requests for incoming JSON-RPC messages from clients.
*/
private void handlePostRequest(ChannelHandlerContext ctx, FullHttpRequest request) {
List<String> badRequestErrors = new ArrayList<>();
String accept = request.headers().get(ACCEPT);
if (accept == null || !accept.contains(TEXT_EVENT_STREAM)) {
badRequestErrors.add("text/event-stream required in Accept header");
}
if (accept == null || !accept.contains(APPLICATION_JSON)) {
badRequestErrors.add("application/json required in Accept header");
}
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
try {
ByteBuf content = request.content();
String body = content.toString(CharsetUtil.UTF_8);
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
// Handle initialization request
if (message instanceof McpSchema.JSONRPCRequest) {
McpSchema.JSONRPCRequest jsonrpcRequest = (McpSchema.JSONRPCRequest) message;
if (jsonrpcRequest.getMethod().equals(McpSchema.METHOD_INITIALIZE)) {
if (!badRequestErrors.isEmpty()) {
String combinedMessage = String.join("; ", badRequestErrors);
sendError(ctx, HttpResponseStatus.BAD_REQUEST, new McpError(combinedMessage));
return;
}
McpSchema.InitializeRequest initializeRequest = objectMapper.convertValue(jsonrpcRequest.getParams(),
new TypeReference<McpSchema.InitializeRequest>() {
});
McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory
.startSession(initializeRequest);
this.sessions.put(init.session().getId(), init.session());
try {
init.initResult()
.thenAccept(initResult -> {
try {
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.OK,
Unpooled.copiedBuffer(objectMapper.writeValueAsString(
new McpSchema.JSONRPCResponse(
McpSchema.JSONRPC_VERSION, jsonrpcRequest.getId(), initResult, null)),
CharsetUtil.UTF_8)
);
response.headers().set(HttpHeaderNames.CONTENT_TYPE, APPLICATION_JSON);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
response.headers().set(HttpHeaders.MCP_SESSION_ID, init.session().getId());
response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
ctx.writeAndFlush(response);
} catch (Exception e) {
logger.error("Failed to serialize init response: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Failed to serialize response"));
}
})
.exceptionally(e -> {
logger.error("Failed to initialize session: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Failed to initialize session: " + e.getMessage()));
return null;
});
return;
} catch (Exception e) {
logger.error("Failed to initialize session: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Failed to initialize session: " + e.getMessage()));
return;
}
}
}
String sessionId = request.headers().get(HttpHeaders.MCP_SESSION_ID);
if (sessionId == null || sessionId.trim().isEmpty()) {
badRequestErrors.add("Session ID required in mcp-session-id header");
}
if (!badRequestErrors.isEmpty()) {
String combinedMessage = String.join("; ", badRequestErrors);
sendError(ctx, HttpResponseStatus.BAD_REQUEST, new McpError(combinedMessage));
return;
}
McpStreamableServerSession session = this.sessions.get(sessionId);
if (session == null) {
sendError(ctx, HttpResponseStatus.NOT_FOUND,
new McpError("Session not found: " + sessionId));
return;
}
if (message instanceof McpSchema.JSONRPCResponse) {
McpSchema.JSONRPCResponse jsonrpcResponse = (McpSchema.JSONRPCResponse) message;
session.accept(jsonrpcResponse)
.thenRun(() -> {
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.ACCEPTED
);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
ctx.writeAndFlush(response);
})
.exceptionally(e -> {
logger.error("Failed to accept response: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR, new McpError("Failed to accept response"));
return null;
});
} else if (message instanceof McpSchema.JSONRPCNotification) {
McpSchema.JSONRPCNotification jsonrpcNotification = (McpSchema.JSONRPCNotification) message;
session.accept(jsonrpcNotification, transportContext)
.thenRun(() -> {
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.ACCEPTED
);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
ctx.writeAndFlush(response);
})
.exceptionally(e -> {
logger.error("Failed to accept notification: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR, new McpError("Failed to accept notification"));
return null;
});
} else if (message instanceof McpSchema.JSONRPCRequest) {
McpSchema.JSONRPCRequest jsonrpcRequest = (McpSchema.JSONRPCRequest) message;
// For streaming responses, we need to return SSE
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, "keep-alive");
response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED);
ctx.writeAndFlush(response);
NettyStreamableMcpSessionTransport sessionTransport = new NettyStreamableMcpSessionTransport(
sessionId, ctx);
try {
session.responseStream(jsonrpcRequest, sessionTransport, transportContext)
.exceptionally(e -> {
logger.error("Failed to handle request stream: {}", e.getMessage());
ctx.close();
return null;
});
} catch (Exception e) {
logger.error("Failed to handle request stream: {}", e.getMessage());
ctx.close();
}
} else {
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Unknown message type"));
}
} catch (IllegalArgumentException | IOException e) {
logger.error("Failed to deserialize message: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.BAD_REQUEST,
new McpError("Invalid message format: " + e.getMessage()));
} catch (Exception e) {
logger.error("Error handling message: {}", e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Error processing message: " + e.getMessage()));
}
}
/**
* Handles DELETE requests for session deletion.
*/
private void handleDeleteRequest(ChannelHandlerContext ctx, FullHttpRequest request) {
if (this.disallowDelete) {
sendError(ctx, HttpResponseStatus.METHOD_NOT_ALLOWED, new McpError("DELETE method not allowed"));
return;
}
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
String sessionId = request.headers().get(HttpHeaders.MCP_SESSION_ID);
if (sessionId == null) {
sendError(ctx, HttpResponseStatus.BAD_REQUEST,
new McpError("Session ID required in mcp-session-id header"));
return;
}
McpStreamableServerSession session = this.sessions.get(sessionId);
if (session == null) {
sendError(ctx, HttpResponseStatus.NOT_FOUND, new McpError("Session not found"));
return;
}
try {
session.delete()
.thenRun(() -> {
this.sessions.remove(sessionId);
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.OK
);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
ctx.writeAndFlush(response);
})
.exceptionally(e -> {
logger.error("Failed to delete session {}: {}", sessionId, e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError(e.getMessage()));
return null;
});
} catch (Exception e) {
logger.error("Failed to delete session {}: {}", sessionId, e.getMessage());
sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
new McpError("Error deleting session"));
}
}
/**
* Sends an error response to the client.
*/
private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status, McpError mcpError) {
try {
String jsonError = objectMapper.writeValueAsString(mcpError);
ByteBuf content = Unpooled.copiedBuffer(jsonError, 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, response.content().readableBytes());
response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
ctx.writeAndFlush(response);
} catch (Exception e) {
logger.error(FAILED_TO_SEND_ERROR_RESPONSE, e.getMessage());
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.INTERNAL_SERVER_ERROR
);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
ctx.writeAndFlush(response);
}
}
// Implementation of McpStreamableServerTransport for Netty SSE sessions
private class NettyStreamableMcpSessionTransport implements McpStreamableServerTransport {
private final String sessionId;
private final ChannelHandlerContext ctx;
private final AtomicBoolean closed = new AtomicBoolean(false);
private final ReentrantLock lock = new ReentrantLock();
NettyStreamableMcpSessionTransport(String sessionId, ChannelHandlerContext ctx) {
this.sessionId = sessionId;
this.ctx = ctx;
logger.debug("Streamable session transport {} initialized", sessionId);
}
@Override
public CompletableFuture<Void> sendMessage(McpSchema.JSONRPCMessage message) {
return sendMessage(message, null);
}
@Override
public CompletableFuture<Void> sendMessage(McpSchema.JSONRPCMessage message, String messageId) {
return CompletableFuture.runAsync(() -> {
if (this.closed.get()) {
logger.warn("Attempted to send message to closed session: {}", this.sessionId);
return;
}
// Check if channel is still active
if (!this.ctx.channel().isActive()) {
logger.warn("Channel for session {} is not active, message will not be sent", this.sessionId);
return;
}
lock.lock();
try {
if (this.closed.get()) {
logger.debug("Session {} was closed during message send attempt", this.sessionId);
return;
}
String jsonText = objectMapper.writeValueAsString(message);
logger.debug("Sending SSE message to session {}: {}", this.sessionId,
jsonText.length() > 200 ? jsonText.substring(0, 200) + "..." : jsonText);
sendSseEvent(MESSAGE_EVENT_TYPE, jsonText, messageId != null ? messageId : this.sessionId);
logger.debug("Message sent to session {} with ID {}", this.sessionId, messageId);
} catch (Exception e) {
logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage());
this.ctx.close();
} finally {
lock.unlock();
}
});
}
@Override
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
return objectMapper.convertValue(data, typeRef);
}
@Override
public CompletableFuture<Void> closeGracefully() {
return CompletableFuture.runAsync(this::close);
}
@Override
public void close() {
lock.lock();
try {
if (this.closed.get()) {
logger.debug("Session transport {} already closed", this.sessionId);
return;
}
this.closed.set(true);
this.ctx.close();
logger.debug("Successfully closed session transport {}", sessionId);
} catch (Exception e) {
logger.warn("Failed to close session transport {}: {}", sessionId, e.getMessage());
} finally {
lock.unlock();
}
}
@Override
public Channel getChannel() {
return ctx.channel();
}
private void sendSseEvent(String eventType, String data, String id) {
StringBuilder sseData = new StringBuilder();
if (id != null) {
sseData.append("id: ").append(id).append("\n");
}
sseData.append("event: ").append(eventType).append("\n");
sseData.append("data: ").append(data).append("\n\n");
ByteBuf buffer = Unpooled.copiedBuffer(sseData.toString(), CharsetUtil.UTF_8);
this.ctx.writeAndFlush(new DefaultHttpContent(buffer));
logger.debug("SSE event sent - Type: {}, ID: {}, Data length: {}",
eventType, id, data != null ? data.length() : 0);
}
}
public static Builder builder() {
return new Builder();
}
/**
* Builder for creating instances of {@link McpStreamableHttpRequestHandler}.
*/
public static class Builder {
private ObjectMapper objectMapper;
private String mcpEndpoint = "/mcp";
private boolean disallowDelete = false;
private McpTransportContextExtractor<FullHttpRequest> contextExtractor = (serverRequest, context) -> context;
private Duration keepAliveInterval;
public Builder objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
public Builder mcpEndpoint(String mcpEndpoint) {
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
this.mcpEndpoint = mcpEndpoint;
return this;
}
public Builder disallowDelete(boolean disallowDelete) {
this.disallowDelete = disallowDelete;
return this;
}
public Builder contextExtractor(McpTransportContextExtractor<FullHttpRequest> contextExtractor) {
Assert.notNull(contextExtractor, "Context extractor must not be null");
this.contextExtractor = contextExtractor;
return this;
}
public Builder keepAliveInterval(Duration keepAliveInterval) {
this.keepAliveInterval = keepAliveInterval;
return this;
}
public McpStreamableHttpRequestHandler build() {
Assert.notNull(this.objectMapper, "ObjectMapper must be set");
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new McpStreamableHttpRequestHandler(this.objectMapper, this.mcpEndpoint,
this.disallowDelete, this.contextExtractor, this.keepAliveInterval);
}
}
}
@@ -0,0 +1,155 @@
package com.taobao.arthas.mcp.server.protocol.server.store;
import com.taobao.arthas.mcp.server.protocol.spec.EventStore;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* In-memory implementation of EventStore.
*
* @author Yeaury
*/
public class InMemoryEventStore implements EventStore {
private static final Logger logger = LoggerFactory.getLogger(InMemoryEventStore.class);
/** Global event ID counter */
private final AtomicLong globalEventIdCounter = new AtomicLong(0);
/**
* Events storage: sessionId -> list of events
*/
private final Map<String, List<StoredEvent>> sessionEvents = new ConcurrentHashMap<>();
/**
* Event ID to session mapping for fast lookup
*/
private final Map<String, String> eventIdToSession = new ConcurrentHashMap<>();
/**
* Maximum events to keep per session (prevent memory leaks)
*/
private final int maxEventsPerSession;
/**
* Default retention time in milliseconds (24 hours)
*/
private final long defaultRetentionMs;
public InMemoryEventStore() {
this(1000, 24 * 60 * 60 * 1000L); // 1000 events, 24 hours
}
public InMemoryEventStore(int maxEventsPerSession, long defaultRetentionMs) {
this.maxEventsPerSession = maxEventsPerSession;
this.defaultRetentionMs = defaultRetentionMs;
}
@Override
public String storeEvent(String sessionId, McpSchema.JSONRPCMessage message) {
String eventId = String.valueOf(globalEventIdCounter.incrementAndGet());
Instant timestamp = Instant.now();
StoredEvent event = new StoredEvent(eventId, sessionId, message, timestamp);
sessionEvents.computeIfAbsent(sessionId, k -> new ArrayList<>()).add(event);
eventIdToSession.put(eventId, sessionId);
// Cleanup old events if needed
List<StoredEvent> events = sessionEvents.get(sessionId);
if (events.size() > maxEventsPerSession) {
// Remove oldest events
int toRemove = events.size() - maxEventsPerSession;
for (int i = 0; i < toRemove; i++) {
StoredEvent removedEvent = events.remove(0);
eventIdToSession.remove(removedEvent.getEventId());
}
logger.debug("Cleaned up {} old events for session {}", toRemove, sessionId);
}
logger.trace("Stored event {} for session {}", eventId, sessionId);
return eventId;
}
@Override
public Stream<StoredEvent> getEventsForSession(String sessionId, String fromEventId) {
List<StoredEvent> events = sessionEvents.get(sessionId);
if (events == null || events.isEmpty()) {
return Stream.empty();
}
if (fromEventId == null) {
return Stream.empty();
}
boolean foundStartEvent = false;
List<StoredEvent> result = new ArrayList<>();
for (StoredEvent event : events) {
if (!foundStartEvent) {
if (event.getEventId().equals(fromEventId)) {
foundStartEvent = true;
result.add(event);
// clear the replayed events
events.remove(event);
eventIdToSession.remove(event.getEventId());
}
continue;
}
result.add(event);
}
return result.stream();
}
@Override
public void cleanupOldEvents(String sessionId, long maxAge) {
List<StoredEvent> events = sessionEvents.get(sessionId);
if (events == null || events.isEmpty()) {
return;
}
Instant cutoff = Instant.now().minusMillis(maxAge);
List<StoredEvent> toRemove = events.stream()
.filter(event -> event.getTimestamp().isBefore(cutoff))
.collect(Collectors.toList());
for (StoredEvent event : toRemove) {
events.remove(event);
eventIdToSession.remove(event.getEventId());
}
if (!toRemove.isEmpty()) {
logger.debug("Cleaned up {} old events for session {}", toRemove.size(), sessionId);
}
}
@Override
public void removeSessionEvents(String sessionId) {
List<StoredEvent> events = sessionEvents.remove(sessionId);
if (events != null) {
for (StoredEvent event : events) {
eventIdToSession.remove(event.getEventId());
}
logger.debug("Removed {} events for session {}", events.size(), sessionId);
}
}
public void cleanupExpiredEvents() {
for (String sessionId : sessionEvents.keySet()) {
cleanupOldEvents(sessionId, defaultRetentionMs);
}
}
}
@@ -1,90 +0,0 @@
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<Void> notifyClients(String method, Object params) {
return mcpRequestHandler.notifyClients(method, params);
}
@Override
public CompletableFuture<Void> 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();
}
}
@@ -0,0 +1,112 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server.transport;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.taobao.arthas.mcp.server.protocol.server.McpStatelessServerHandler;
import com.taobao.arthas.mcp.server.protocol.server.McpTransportContextExtractor;
import com.taobao.arthas.mcp.server.protocol.server.handler.McpStatelessHttpRequestHandler;
import com.taobao.arthas.mcp.server.protocol.spec.McpStatelessServerTransport;
import com.taobao.arthas.mcp.server.util.Assert;
import io.netty.handler.codec.http.FullHttpRequest;
import java.util.concurrent.CompletableFuture;
/**
* Server-side implementation of the Model Context Protocol (MCP) stateless transport
* layer using HTTP through Netty. This implementation provides a bridge between
* Netty operations and the MCP transport interface for stateless operations.
*
* @see McpStatelessServerTransport
*/
public class NettyStatelessServerTransport implements McpStatelessServerTransport {
private final McpStatelessHttpRequestHandler requestHandler;
/**
* Constructs a new NettyStatelessServerTransportProvider instance.
*
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
* of messages.
* @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC
* messages via HTTP.
* @param contextExtractor The extractor for transport context from the request.
* @throws IllegalArgumentException if any parameter is null
*/
private NettyStatelessServerTransport(ObjectMapper objectMapper, String mcpEndpoint,
McpTransportContextExtractor<FullHttpRequest> contextExtractor) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
this.requestHandler = new McpStatelessHttpRequestHandler(objectMapper, mcpEndpoint, contextExtractor);
}
@Override
public void setMcpHandler(McpStatelessServerHandler mcpHandler) {
requestHandler.setMcpHandler(mcpHandler);
}
/**
* Initiates a graceful shutdown of the transport.
*
* @return A CompletableFuture that completes when all cleanup operations are finished
*/
@Override
public CompletableFuture<Void> closeGracefully() {
return requestHandler.closeGracefully();
}
/**
* Gets the underlying HTTP request handler.
*
* @return The McpStatelessHttpRequestHandler instance
*/
public McpStatelessHttpRequestHandler getMcpRequestHandler() {
if (this.requestHandler != null) {
return this.requestHandler;
}
throw new UnsupportedOperationException("Stateless transport provider does not support request handler");
}
public static Builder builder() {
return new Builder();
}
/**
* Builder for creating instances of {@link NettyStatelessServerTransport}.
*/
public static class Builder {
private ObjectMapper objectMapper;
private String mcpEndpoint = "/mcp";
private McpTransportContextExtractor<FullHttpRequest> contextExtractor = (serverRequest, context) -> context;
public Builder objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
public Builder mcpEndpoint(String mcpEndpoint) {
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
this.mcpEndpoint = mcpEndpoint;
return this;
}
public Builder contextExtractor(McpTransportContextExtractor<FullHttpRequest> contextExtractor) {
Assert.notNull(contextExtractor, "Context extractor must not be null");
this.contextExtractor = contextExtractor;
return this;
}
public NettyStatelessServerTransport build() {
Assert.notNull(this.objectMapper, "ObjectMapper must be set");
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new NettyStatelessServerTransport(this.objectMapper, this.mcpEndpoint, this.contextExtractor);
}
}
}
@@ -0,0 +1,148 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.server.transport;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.taobao.arthas.mcp.server.protocol.server.McpTransportContextExtractor;
import com.taobao.arthas.mcp.server.protocol.server.handler.McpStreamableHttpRequestHandler;
import com.taobao.arthas.mcp.server.protocol.spec.McpStreamableServerSession;
import com.taobao.arthas.mcp.server.protocol.spec.McpStreamableServerTransportProvider;
import com.taobao.arthas.mcp.server.util.Assert;
import io.netty.handler.codec.http.FullHttpRequest;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
/**
* Server-side implementation of the Model Context Protocol (MCP) streamable transport
* layer using HTTP with Server-Sent Events (SSE) through Netty. This implementation
* provides a bridge between Netty operations and the MCP transport interface.
*
* @see McpStreamableServerTransportProvider
*/
public class NettyStreamableServerTransportProvider implements McpStreamableServerTransportProvider {
private final McpStreamableHttpRequestHandler requestHandler;
/**
* Constructs a new NettyStreamableServerTransportProvider instance.
*
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
* of messages.
* @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC
* messages via HTTP.
* @param disallowDelete Whether to disallow DELETE requests on the endpoint.
* @param contextExtractor The extractor for transport context from the request.
* @param keepAliveInterval Interval for keep-alive pings (null to disable)
* @throws IllegalArgumentException if any parameter is null
*/
private NettyStreamableServerTransportProvider(ObjectMapper objectMapper, String mcpEndpoint,
boolean disallowDelete, McpTransportContextExtractor<FullHttpRequest> contextExtractor,
Duration keepAliveInterval) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
this.requestHandler = new McpStreamableHttpRequestHandler(objectMapper, mcpEndpoint, disallowDelete, contextExtractor, keepAliveInterval);
}
@Override
public String protocolVersion() {
return "2025-03-26";
}
@Override
public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) {
requestHandler.setSessionFactory(sessionFactory);
}
/**
* Broadcasts a notification to all connected clients through their SSE connections.
*
* @param method The method name for the notification
* @param params The parameters for the notification
* @return A CompletableFuture that completes when the broadcast attempt is finished
*/
@Override
public CompletableFuture<Void> notifyClients(String method, Object params) {
return requestHandler.notifyClients(method, params);
}
/**
* Initiates a graceful shutdown of the transport.
*
* @return A CompletableFuture that completes when all cleanup operations are finished
*/
@Override
public CompletableFuture<Void> closeGracefully() {
return requestHandler.closeGracefully();
}
@Override
public McpStreamableHttpRequestHandler getMcpRequestHandler() {
if (this.requestHandler != null) {
return this.requestHandler;
}
throw new UnsupportedOperationException("Streamable transport provider does not support legacy SSE request handler");
}
public static Builder builder() {
return new Builder();
}
/**
* Builder for creating instances of {@link NettyStreamableServerTransportProvider}.
*/
public static class Builder {
private ObjectMapper objectMapper;
private String mcpEndpoint = "/mcp";
private boolean disallowDelete = false;
private McpTransportContextExtractor<FullHttpRequest> contextExtractor = (serverRequest, context) -> context;
private Duration keepAliveInterval;
public Builder objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
public Builder mcpEndpoint(String mcpEndpoint) {
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
this.mcpEndpoint = mcpEndpoint;
return this;
}
public Builder disallowDelete(boolean disallowDelete) {
this.disallowDelete = disallowDelete;
return this;
}
public Builder contextExtractor(McpTransportContextExtractor<FullHttpRequest> contextExtractor) {
Assert.notNull(contextExtractor, "Context extractor must not be null");
this.contextExtractor = contextExtractor;
return this;
}
public Builder keepAliveInterval(Duration keepAliveInterval) {
this.keepAliveInterval = keepAliveInterval;
return this;
}
public NettyStreamableServerTransportProvider build() {
Assert.notNull(this.objectMapper, "ObjectMapper must be set");
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new NettyStreamableServerTransportProvider(this.objectMapper, this.mcpEndpoint,
this.disallowDelete, this.contextExtractor, this.keepAliveInterval);
}
}
// Placeholder interface for KeepAliveScheduler if not already implemented
private interface KeepAliveScheduler {
void shutdown();
}
}
@@ -0,0 +1,64 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import com.taobao.arthas.mcp.server.CommandExecutor;
import com.taobao.arthas.mcp.server.protocol.server.McpInitRequestHandler;
import com.taobao.arthas.mcp.server.protocol.server.McpNotificationHandler;
import com.taobao.arthas.mcp.server.protocol.server.McpRequestHandler;
import com.taobao.arthas.mcp.server.protocol.server.store.InMemoryEventStore;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* Default implementation of the streamable server session factory.
* This factory creates new MCP streamable server sessions with the provided configuration.
*
*/
public class DefaultMcpStreamableServerSessionFactory implements McpStreamableServerSession.Factory {
private final Duration requestTimeout;
private final McpInitRequestHandler mcpInitRequestHandler;
private final Map<String, McpRequestHandler<?>> requestHandlers;
private final Map<String, McpNotificationHandler> notificationHandlers;
private final CommandExecutor commandExecutor;
public DefaultMcpStreamableServerSessionFactory(Duration requestTimeout,
McpInitRequestHandler mcpInitRequestHandler,
Map<String, McpRequestHandler<?>> requestHandlers,
Map<String, McpNotificationHandler> notificationHandlers,
CommandExecutor commandExecutor) {
this.requestTimeout = requestTimeout;
this.mcpInitRequestHandler = mcpInitRequestHandler;
this.requestHandlers = requestHandlers;
this.notificationHandlers = notificationHandlers;
this.commandExecutor = commandExecutor;
}
@Override
public McpStreamableServerSession.McpStreamableServerSessionInit startSession(
McpSchema.InitializeRequest initializeRequest) {
// Create a new session with a unique ID
McpStreamableServerSession session = new McpStreamableServerSession(
UUID.randomUUID().toString(),
initializeRequest.getCapabilities(),
initializeRequest.getClientInfo(),
requestTimeout,
requestHandlers,
notificationHandlers,
commandExecutor,
new InMemoryEventStore());
// Handle the initialization request
CompletableFuture<McpSchema.InitializeResult> initResult =
this.mcpInitRequestHandler.handle(initializeRequest);
return new McpStreamableServerSession.McpStreamableServerSessionInit(session, initResult);
}
}
@@ -0,0 +1,51 @@
package com.taobao.arthas.mcp.server.protocol.spec;
import java.time.Instant;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Stream;
/**
* EventStore interface for storing and replaying JSON-RPC events.
* Supports event persistence and stream resumability for MCP Streamable HTTP protocol.
*
* @author Yeaury
*/
public interface EventStore {
class StoredEvent {
private final String eventId;
private final String sessionId;
private final McpSchema.JSONRPCMessage message;
private final Instant timestamp;
public StoredEvent(String eventId, String sessionId, McpSchema.JSONRPCMessage message, Instant timestamp) {
this.eventId = eventId;
this.sessionId = sessionId;
this.message = message;
this.timestamp = timestamp;
}
public String getEventId() {
return eventId;
}
public String getSessionId() {
return sessionId;
}
public McpSchema.JSONRPCMessage getMessage() {
return message;
}
public Instant getTimestamp() {
return timestamp;
}
}
String storeEvent(String sessionId, McpSchema.JSONRPCMessage message);
Stream<StoredEvent> getEventsForSession(String sessionId, String fromEventId);
void cleanupOldEvents(String sessionId, long maxAge);
void removeSessionEvents(String sessionId);
}
@@ -0,0 +1,24 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
public interface HttpHeaders {
/**
* Identifies individual MCP sessions.
*/
String MCP_SESSION_ID = "mcp-session-id";
/**
* Identifies events within an SSE Stream.
*/
String LAST_EVENT_ID = "last-event-id";
/**
* Identifies the MCP protocol version.
*/
String PROTOCOL_VERSION = "MCP-Protocol-Version";
}
@@ -1,3 +1,7 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema.JSONRPCResponse.JSONRPCError;
@@ -1,3 +1,7 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import com.fasterxml.jackson.annotation.*;
@@ -9,11 +13,7 @@ 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;
import java.util.*;
/**
* Based on the <a href="http://www.jsonrpc.org/specification">JSON-RPC 2.0 specification</a>
@@ -29,7 +29,7 @@ public final class McpSchema {
private McpSchema() {
}
public static final String LATEST_PROTOCOL_VERSION = "2024-11-05";
public static final String LATEST_PROTOCOL_VERSION = "2025-03-26";
public static final String JSONRPC_VERSION = "2.0";
@@ -44,6 +44,8 @@ public final class McpSchema {
public static final String METHOD_PING = "ping";
public static final String METHOD_NOTIFICATION_PROGRESS = "notifications/progress";
// Tool Methods
public static final String METHOD_TOOLS_LIST = "tools/list";
@@ -82,15 +84,6 @@ public final class McpSchema {
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
// ---------------------------
@@ -1302,16 +1295,24 @@ public final class McpSchema {
public static class CallToolRequest implements Request {
private final String name;
private final Map<String, Object> arguments;
private final Map<String, Object> meta;
public CallToolRequest(
@JsonProperty("name") String name,
@JsonProperty("arguments") Map<String, Object> arguments) {
@JsonProperty("arguments") Map<String, Object> arguments,
@JsonProperty("_meta") Map<String, Object> meta) {
this.name = name;
this.arguments = arguments;
this.meta = meta;
}
public CallToolRequest(String name, String jsonArguments) {
this(name, parseJsonArguments(jsonArguments));
this(name, parseJsonArguments(jsonArguments), null);
}
public CallToolRequest(String name, Map<String, Object> arguments) {
this(name, arguments, null);
}
private static Map<String, Object> parseJsonArguments(String jsonArguments) {
@@ -1330,6 +1331,56 @@ public final class McpSchema {
public Map<String, Object> getArguments() {
return arguments;
}
public Map<String, Object> getMeta() {
return meta;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String name;
private Map<String, Object> arguments;
private Map<String, Object> meta;
public Builder name(String name) {
this.name = name;
return this;
}
public Builder arguments(Map<String, Object> arguments) {
this.arguments = arguments;
return this;
}
public Builder arguments(String jsonArguments) {
this.arguments = parseJsonArguments(jsonArguments);
return this;
}
public Builder meta(Map<String, Object> meta) {
this.meta = meta;
return this;
}
public Builder progressToken(String progressToken) {
if (this.meta == null) {
this.meta = new HashMap<>();
}
this.meta.put("progressToken", progressToken);
return this;
}
public CallToolRequest build() {
Assert.hasText(name, "name must not be empty");
return new CallToolRequest(name, arguments, meta);
}
}
}
/**
@@ -1661,12 +1712,12 @@ public final class McpSchema {
public static class LoggingMessageNotification {
private final LoggingLevel level;
private final String logger;
private final String data;
private final Object data;
public LoggingMessageNotification(
@JsonProperty("level") LoggingLevel level,
@JsonProperty("logger") String logger,
@JsonProperty("data") String data) {
@JsonProperty("data") Object data) {
this.level = level;
this.logger = logger;
this.data = data;
@@ -1680,7 +1731,7 @@ public final class McpSchema {
return logger;
}
public String getData() {
public Object getData() {
return data;
}
@@ -1691,7 +1742,7 @@ public final class McpSchema {
public static class Builder {
private LoggingLevel level = LoggingLevel.INFO;
private String logger = "server";
private String data;
private Object data;
public Builder level(LoggingLevel level) {
this.level = level;
@@ -1703,7 +1754,7 @@ public final class McpSchema {
return this;
}
public Builder data(String data) {
public Builder data(Object data) {
this.data = data;
return this;
}
@@ -1750,6 +1801,32 @@ public final class McpSchema {
}
}
/**
* Notification for sending intermediate results during streaming tool execution.
* This allows tools to send partial results to clients in real-time.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public static class IntermediateResultNotification {
private final String type;
private final Object data;
public IntermediateResultNotification(
@JsonProperty("type") String type,
@JsonProperty("data") Object data) {
this.type = type;
this.data = data;
}
public String getType() {
return type;
}
public Object getData() {
return data;
}
}
// ---------------------------
// Autocomplete
// ---------------------------
@@ -1,433 +0,0 @@
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<Object, CompletableFuture<McpSchema.JSONRPCResponse>> 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<String, RequestHandler<?>> requestHandlers;
private final Map<String, NotificationHandler> notificationHandlers;
private final McpServerTransport transport;
private final CompletableFuture<McpNettyServerExchange> exchangeFuture = new CompletableFuture<>();
private final AtomicReference<McpSchema.ClientCapabilities> clientCapabilities = new AtomicReference<>();
private final AtomicReference<McpSchema.Implementation> 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<String, RequestHandler<?>> requestHandlers, Map<String, NotificationHandler> 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 <T> CompletableFuture<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef) {
String requestId = this.generateRequestId();
CompletableFuture<T> result = new CompletableFuture<>();
CompletableFuture<McpSchema.JSONRPCResponse> 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<Void> 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<Void> handle(McpSchema.JSONRPCMessage message) {
CompletableFuture<Void> 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<Void> result) {
logger.debug("Received response: {}", response);
CompletableFuture<McpSchema.JSONRPCResponse> 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<Void> 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<Void> 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<McpSchema.JSONRPCResponse> 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<McpSchema.InitializeRequest>() {
});
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<McpSchema.JSONRPCResponse> 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<McpSchema.JSONRPCResponse> 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<McpSchema.JSONRPCResponse> 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<McpSchema.JSONRPCResponse> 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<Object> typedHandler = (RequestHandler<Object>) 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<McpSchema.JSONRPCResponse> 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<Void> 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<Void> 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<Void> 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<String, String> data = new HashMap<>();
data.put("method", method);
return new MethodNotFoundError(method, "method not found: " + method, data);
}
@Override
public CompletableFuture<Void> closeGracefully() {
return this.transport.closeGracefully();
}
@Override
public void close() {
this.transport.close();
}
public interface InitRequestHandler {
CompletableFuture<McpSchema.InitializeResult> handle(McpSchema.InitializeRequest initializeRequest);
}
public interface InitNotificationHandler {
CompletableFuture<Void> handle();
}
public interface NotificationHandler {
CompletableFuture<Void> handle(McpNettyServerExchange exchange, Object params);
}
public interface RequestHandler<T> {
CompletableFuture<T> handle(McpNettyServerExchange exchange, Object params);
}
@FunctionalInterface
public interface Factory {
McpServerSession create(McpServerTransport sessionTransport);
}
}
@@ -1,3 +1,7 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import io.netty.channel.Channel;
@@ -1,6 +1,10 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import com.taobao.arthas.mcp.server.protocol.server.handler.McpRequestHandler;
import com.taobao.arthas.mcp.server.protocol.server.handler.McpStreamableHttpRequestHandler;
import java.util.concurrent.CompletableFuture;
@@ -12,8 +16,6 @@ import java.util.concurrent.CompletableFuture;
*/
public interface McpServerTransportProvider {
void setSessionFactory(McpServerSession.Factory sessionFactory);
CompletableFuture<Void> notifyClients(String method, Object params);
CompletableFuture<Void> closeGracefully();
@@ -22,5 +24,7 @@ public interface McpServerTransportProvider {
closeGracefully();
}
McpRequestHandler getMcpRequestHandler();
McpStreamableHttpRequestHandler getMcpRequestHandler();
String protocolVersion();
}
@@ -1,3 +1,7 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import java.util.concurrent.CompletableFuture;
@@ -0,0 +1,21 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import com.taobao.arthas.mcp.server.protocol.server.McpStatelessServerHandler;
import java.util.concurrent.CompletableFuture;
public interface McpStatelessServerTransport {
void setMcpHandler(McpStatelessServerHandler mcpHandler);
default void close() {
this.closeGracefully();
}
CompletableFuture<Void> closeGracefully();
}
@@ -0,0 +1,428 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import com.fasterxml.jackson.core.type.TypeReference;
import com.taobao.arthas.mcp.server.CommandExecutor;
import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange;
import com.taobao.arthas.mcp.server.protocol.server.McpNotificationHandler;
import com.taobao.arthas.mcp.server.protocol.server.McpRequestHandler;
import com.taobao.arthas.mcp.server.protocol.server.McpTransportContext;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.session.ArthasCommandSessionManager;
import com.taobao.arthas.mcp.server.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import java.util.stream.Stream;
/**
* Main implementation of a streamable MCP server session that manages client connections
* and handles JSON-RPC communication using CompletableFuture for async operations.
*/
public class McpStreamableServerSession implements McpSession {
private static final Logger logger = LoggerFactory.getLogger(McpStreamableServerSession.class);
private final ConcurrentHashMap<Object, McpStreamableServerSessionStream> requestIdToStream = new ConcurrentHashMap<>();
private final String id;
private final Duration requestTimeout;
private final AtomicLong requestCounter = new AtomicLong(0);
private final Map<String, McpRequestHandler<?>> requestHandlers;
private final Map<String, McpNotificationHandler> notificationHandlers;
private final AtomicReference<McpSchema.ClientCapabilities> clientCapabilities = new AtomicReference<>();
private final AtomicReference<McpSchema.Implementation> clientInfo = new AtomicReference<>();
private final AtomicReference<McpSession> listeningStreamRef;
private final MissingMcpTransportSession missingMcpTransportSession;
private volatile McpSchema.LoggingLevel minLoggingLevel = McpSchema.LoggingLevel.INFO;
private final CommandExecutor commandExecutor;
private final ArthasCommandSessionManager commandSessionManager;
private final EventStore eventStore;
public McpStreamableServerSession(String id, McpSchema.ClientCapabilities clientCapabilities,
McpSchema.Implementation clientInfo, Duration requestTimeout,
Map<String, McpRequestHandler<?>> requestHandlers,
Map<String, McpNotificationHandler> notificationHandlers,
CommandExecutor commandExecutor, EventStore eventStore) {
this.id = id;
this.missingMcpTransportSession = new MissingMcpTransportSession(id);
this.listeningStreamRef = new AtomicReference<>(this.missingMcpTransportSession);
this.clientCapabilities.lazySet(clientCapabilities);
this.clientInfo.lazySet(clientInfo);
this.requestTimeout = requestTimeout;
this.requestHandlers = requestHandlers;
this.notificationHandlers = notificationHandlers;
this.commandExecutor = commandExecutor;
this.commandSessionManager = new ArthasCommandSessionManager(commandExecutor);
this.eventStore = eventStore;
}
/**
* Sets the minimum logging level for this session.
* @param minLoggingLevel the minimum logging level
*/
public void setMinLoggingLevel(McpSchema.LoggingLevel minLoggingLevel) {
Assert.notNull(minLoggingLevel, "minLoggingLevel must not be null");
this.minLoggingLevel = minLoggingLevel;
}
/**
* Checks if notifications for the given logging level are allowed.
* @param loggingLevel the logging level to check
* @return true if notifications for this level are allowed
*/
public boolean isNotificationForLevelAllowed(McpSchema.LoggingLevel loggingLevel) {
return loggingLevel.level() >= this.minLoggingLevel.level();
}
public String getId() {
return this.id;
}
private String generateRequestId() {
return this.id + "-" + this.requestCounter.getAndIncrement();
}
@Override
public <T> CompletableFuture<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef) {
McpSession listeningStream = this.listeningStreamRef.get();
return listeningStream.sendRequest(method, requestParams, typeRef);
}
@Override
public CompletableFuture<Void> sendNotification(String method, Object params) {
McpSession listeningStream = this.listeningStreamRef.get();
return listeningStream.sendNotification(method, params);
}
public CompletableFuture<Void> delete() {
return this.closeGracefully().thenRun(() -> {
try {
eventStore.removeSessionEvents(this.id);
commandSessionManager.closeCommandSession(this.id);
} catch (Exception e) {
logger.warn("Failed to clear session during deletion: {}", e.getMessage());
}
});
}
public McpStreamableServerSessionStream listeningStream(McpStreamableServerTransport transport) {
McpStreamableServerSessionStream listeningStream = new McpStreamableServerSessionStream(transport);
this.listeningStreamRef.set(listeningStream);
return listeningStream;
}
/**
* 重播会话事件,从指定的最后事件ID之后开始
*
* @param lastEventId 最后一个事件ID,如果为null则从头开始重播
* @return 事件消息流
*/
public Stream<McpSchema.JSONRPCMessage> replay(Object lastEventId) {
String lastEventIdStr = lastEventId != null ? lastEventId.toString() : null;
return eventStore.getEventsForSession(this.id, lastEventIdStr)
.map(EventStore.StoredEvent::getMessage);
}
public CompletableFuture<Void> responseStream(McpSchema.JSONRPCRequest jsonrpcRequest,
McpStreamableServerTransport transport, McpTransportContext transportContext) {
McpStreamableServerSessionStream stream = new McpStreamableServerSessionStream(transport);
McpRequestHandler<?> requestHandler = this.requestHandlers.get(jsonrpcRequest.getMethod());
if (requestHandler == null) {
MethodNotFoundError error = getMethodNotFoundError(jsonrpcRequest.getMethod());
McpSchema.JSONRPCResponse errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION,
jsonrpcRequest.getId(), null,
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND,
error.getMessage(), error.getData()));
// 存储错误响应
try {
eventStore.storeEvent(this.id, errorResponse);
} catch (Exception e) {
logger.warn("Failed to store error response event: {}", e.getMessage());
}
return transport.sendMessage(errorResponse, null);
}
ArthasCommandContext commandContext = createCommandContext();
return requestHandler
.handle(new McpNettyServerExchange(this.id, stream, clientCapabilities.get(),
clientInfo.get(), transportContext), commandContext, jsonrpcRequest.getParams())
.thenApply(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION,
jsonrpcRequest.getId(), result, null))
.thenCompose(response -> transport.sendMessage(response, null))
.thenCompose(v -> transport.closeGracefully());
}
public CompletableFuture<Void> accept(McpSchema.JSONRPCNotification notification,
McpTransportContext transportContext) {
McpNotificationHandler notificationHandler = this.notificationHandlers.get(notification.getMethod());
if (notificationHandler == null) {
logger.error("No handler registered for notification method: {}", notification.getMethod());
return CompletableFuture.completedFuture(null);
}
ArthasCommandContext commandContext = createCommandContext();
McpSession listeningStream = this.listeningStreamRef.get();
return notificationHandler.handle(new McpNettyServerExchange(this.id, listeningStream,
this.clientCapabilities.get(), this.clientInfo.get(), transportContext), commandContext, notification.getParams());
}
public CompletableFuture<Void> accept(McpSchema.JSONRPCResponse response) {
McpStreamableServerSessionStream stream = this.requestIdToStream.get(response.getId());
if (stream == null) {
CompletableFuture<Void> f = CompletableFuture.completedFuture(null);
f.completeExceptionally(new McpError("Unexpected response for unknown id " + response.getId()));
return f;
}
CompletableFuture<McpSchema.JSONRPCResponse> future = stream.pendingResponses.remove(response.getId());
if (future == null) {
CompletableFuture<Void> f = CompletableFuture.completedFuture(null);
f.completeExceptionally(new McpError("Unexpected response for unknown id " + response.getId()));
return f;
} else {
future.complete(response);
}
return CompletableFuture.completedFuture(null);
}
public 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;
}
}
private MethodNotFoundError getMethodNotFoundError(String method) {
return new MethodNotFoundError(method, "Method not found: " + method, null);
}
@Override
public CompletableFuture<Void> closeGracefully() {
McpSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession);
// 清理 Arthas 命令会话
try {
commandSessionManager.closeCommandSession(this.id);
logger.debug("Successfully closed command session during graceful shutdown: {}", this.id);
} catch (Exception e) {
logger.warn("Failed to close command session during graceful shutdown: {}", e.getMessage());
}
return listeningStream.closeGracefully();
// TODO: Also close all the open streams
}
@Override
public void close() {
McpSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession);
// 清理 Arthas 命令会话
try {
commandSessionManager.closeCommandSession(this.id);
logger.debug("Successfully closed command session during close: {}", this.id);
} catch (Exception e) {
logger.warn("Failed to close command session during close: {}", e.getMessage());
}
if (listeningStream != null) {
listeningStream.close();
}
// TODO: Also close all open streams
}
public interface Factory {
McpStreamableServerSessionInit startSession(McpSchema.InitializeRequest initializeRequest);
}
public static class McpStreamableServerSessionInit {
private final McpStreamableServerSession session;
private final CompletableFuture<McpSchema.InitializeResult> initResult;
public McpStreamableServerSessionInit(
McpStreamableServerSession session,
CompletableFuture<McpSchema.InitializeResult> initResult) {
this.session = session;
this.initResult = initResult;
}
public McpStreamableServerSession session() {
return session;
}
public CompletableFuture<McpSchema.InitializeResult> initResult() {
return initResult;
}
}
public final class McpStreamableServerSessionStream implements McpSession {
private final ConcurrentHashMap<Object, CompletableFuture<McpSchema.JSONRPCResponse>> pendingResponses = new ConcurrentHashMap<>();
private final McpStreamableServerTransport transport;
private final String transportId;
private final Supplier<String> uuidGenerator;
public McpStreamableServerSessionStream(McpStreamableServerTransport transport) {
this.transport = transport;
this.transportId = UUID.randomUUID().toString();
// This ID design allows for a constant-time extraction of the history by
// precisely identifying the SSE stream using the first component
this.uuidGenerator = () -> this.transportId + "_" + UUID.randomUUID();
}
@Override
public <T> CompletableFuture<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef) {
String requestId = McpStreamableServerSession.this.generateRequestId();
McpStreamableServerSession.this.requestIdToStream.put(requestId, this);
CompletableFuture<McpSchema.JSONRPCResponse> responseFuture = new CompletableFuture<>();
this.pendingResponses.put(requestId, responseFuture);
McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
method, requestId, requestParams);
String messageId = null;
// 存储发送的请求到事件存储
try {
messageId = McpStreamableServerSession.this.eventStore.storeEvent(
McpStreamableServerSession.this.id, jsonrpcRequest);
} catch (Exception e) {
logger.warn("Failed to store outbound request event: {}", e.getMessage());
}
// Send the message
this.transport.sendMessage(jsonrpcRequest, messageId).exceptionally(ex -> {
responseFuture.completeExceptionally(ex);
return null;
});
return responseFuture.handle((jsonRpcResponse, throwable) -> {
// Cleanup
this.pendingResponses.remove(requestId);
McpStreamableServerSession.this.requestIdToStream.remove(requestId);
if (throwable != null) {
if (throwable instanceof RuntimeException) {
throw (RuntimeException) throwable;
}
throw new RuntimeException(throwable);
}
if (jsonRpcResponse.getError() != null) {
throw new RuntimeException(new McpError(jsonRpcResponse.getError()));
} else {
if (typeRef.getType().equals(Void.class)) {
return null;
} else {
return this.transport.unmarshalFrom(jsonRpcResponse.getResult(), typeRef);
}
}
});
}
@Override
public CompletableFuture<Void> sendNotification(String method, Object params) {
McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(
McpSchema.JSONRPC_VERSION, method, params);
String messageId = null;
try {
messageId = McpStreamableServerSession.this.eventStore.storeEvent(
McpStreamableServerSession.this.id, jsonrpcNotification);
} catch (Exception e) {
logger.warn("Failed to store outbound notification event: {}", e.getMessage());
}
return this.transport.sendMessage(jsonrpcNotification, messageId);
}
@Override
public CompletableFuture<Void> closeGracefully() {
// Complete all pending responses with error
this.pendingResponses.values().forEach(future ->
future.completeExceptionally(new RuntimeException("Stream closed")));
this.pendingResponses.clear();
// If this was the generic stream, reset it
McpStreamableServerSession.this.listeningStreamRef.compareAndSet(this,
McpStreamableServerSession.this.missingMcpTransportSession);
McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals);
return this.transport.closeGracefully();
}
@Override
public void close() {
this.pendingResponses.values().forEach(future ->
future.completeExceptionally(new RuntimeException("Stream closed")));
this.pendingResponses.clear();
// If this was the generic stream, reset it
McpStreamableServerSession.this.listeningStreamRef.compareAndSet(this,
McpStreamableServerSession.this.missingMcpTransportSession);
McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals);
this.transport.close();
}
}
/**
* 创建命令执行上下文
*
* @return 命令执行上下文
*/
private ArthasCommandContext createCommandContext() {
ArthasCommandSessionManager.CommandSessionBinding binding = commandSessionManager.getCommandSession(this.id);
return new ArthasCommandContext(commandExecutor, binding);
}
}
@@ -0,0 +1,21 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.concurrent.CompletableFuture;
/**
* Marker interface for the server-side MCP streamable transport.
* This extends the basic server transport with streamable message sending capabilities.
*
*/
public interface McpStreamableServerTransport extends McpServerTransport {
CompletableFuture<Void> sendMessage(McpSchema.JSONRPCMessage message, String messageId);
<T> T unmarshalFrom(Object value, TypeReference<T> typeRef);
}
@@ -0,0 +1,22 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import java.util.concurrent.CompletableFuture;
public interface McpStreamableServerTransportProvider extends McpServerTransportProvider {
void setSessionFactory(McpStreamableServerSession.Factory sessionFactory);
CompletableFuture<Void> notifyClients(String method, Object params);
default void close() {
this.closeGracefully();
}
String protocolVersion();
}
@@ -1,3 +1,7 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import java.util.concurrent.CompletableFuture;
@@ -0,0 +1,52 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.taobao.arthas.mcp.server.protocol.spec;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.concurrent.CompletableFuture;
/**
* A placeholder implementation of McpLoggableSession that represents a missing or unavailable transport session.
* This class is used when no active transport is available but session operations are attempted.
*/
public class MissingMcpTransportSession implements McpSession {
private final String sessionId;
public MissingMcpTransportSession(String sessionId) {
this.sessionId = sessionId;
}
@Override
public <T> CompletableFuture<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef) {
CompletableFuture<T> future = new CompletableFuture<>();
future.completeExceptionally(
new IllegalStateException("Stream unavailable for session " + this.sessionId)
);
return future;
}
@Override
public CompletableFuture<Void> sendNotification(String method, Object params) {
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(
new IllegalStateException("Stream unavailable for session " + this.sessionId)
);
return future;
}
@Override
public CompletableFuture<Void> closeGracefully() {
return CompletableFuture.completedFuture(null);
}
@Override
public void close() {
// Nothing to close for a missing session
}
}
@@ -0,0 +1,76 @@
package com.taobao.arthas.mcp.server.session;
import com.taobao.arthas.mcp.server.CommandExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.Objects;
/**
* 命令执行上下文
*
* @author Yeaury
*/
public class ArthasCommandContext {
private final CommandExecutor commandExecutor;
private final ArthasCommandSessionManager.CommandSessionBinding sessionBinding;
private static final long DEFAULT_SYNC_TIMEOUT = 10000L;
public ArthasCommandContext(CommandExecutor commandExecutor) {
this.commandExecutor = Objects.requireNonNull(commandExecutor, "commandExecutor cannot be null");
this.sessionBinding = null;
}
public ArthasCommandContext(CommandExecutor commandExecutor, ArthasCommandSessionManager.CommandSessionBinding sessionBinding) {
this.commandExecutor = Objects.requireNonNull(commandExecutor, "commandExecutor cannot be null");
this.sessionBinding = Objects.requireNonNull(sessionBinding, "sessionBinding cannot be null");
}
private void requireSessionSupport() {
if (sessionBinding == null) {
throw new IllegalStateException("Session-based operations are not supported in temporary mode. " +
"Use ArthasCommandContext(CommandExecutor, CommandSessionBinding) constructor to enable session support.");
}
}
public boolean isSessionSupported() {
return sessionBinding != null;
}
public Map<String, Object> executeSync(String commandLine) {
return executeSync(commandLine, DEFAULT_SYNC_TIMEOUT);
}
public Map<String, Object> executeSync(String commandLine, long timeout) {
return commandExecutor.executeSync(commandLine, timeout);
}
public Map<String, Object> executeSyncWithSession(String commandLine, long timeout) {
requireSessionSupport();
return commandExecutor.executeSync(commandLine, timeout, sessionBinding.getArthasSessionId());
}
public Map<String, Object> executeAsync(String commandLine) {
requireSessionSupport();
return commandExecutor.executeAsync(commandLine, sessionBinding.getArthasSessionId());
}
public Map<String, Object> pullResults() {
requireSessionSupport();
return commandExecutor.pullResults(sessionBinding.getArthasSessionId(), sessionBinding.getConsumerId());
}
public Map<String, Object> interruptJob() {
requireSessionSupport();
return commandExecutor.interruptJob(sessionBinding.getArthasSessionId());
}
public String getArthasSessionId() {
requireSessionSupport();
return sessionBinding.getArthasSessionId();
}
}
@@ -0,0 +1,137 @@
package com.taobao.arthas.mcp.server.session;
import com.taobao.arthas.mcp.server.CommandExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Arthas命令执行Session管理器
*
* @author Yeaury
*/
public class ArthasCommandSessionManager {
private static final Logger logger = LoggerFactory.getLogger(ArthasCommandSessionManager.class);
private final CommandExecutor commandExecutor;
private final ConcurrentHashMap<String, CommandSessionBinding> sessionBindings = new ConcurrentHashMap<>();
public ArthasCommandSessionManager(CommandExecutor commandExecutor) {
this.commandExecutor = commandExecutor;
}
public static class CommandSessionBinding {
private final String mcpSessionId;
private final String arthasSessionId;
private final String consumerId;
public CommandSessionBinding(String mcpSessionId, String arthasSessionId, String consumerId) {
this.mcpSessionId = mcpSessionId;
this.arthasSessionId = arthasSessionId;
this.consumerId = consumerId;
}
public String getMcpSessionId() {
return mcpSessionId;
}
public String getArthasSessionId() {
return arthasSessionId;
}
public String getConsumerId() {
return consumerId;
}
}
public CommandSessionBinding createCommandSession(String mcpSessionId) {
Map<String, Object> result = commandExecutor.createSession();
CommandSessionBinding binding = new CommandSessionBinding(
mcpSessionId,
(String) result.get("sessionId"),
(String) result.get("consumerId")
);
return binding;
}
public CommandSessionBinding getCommandSession(String mcpSessionId) {
CommandSessionBinding binding = sessionBindings.get(mcpSessionId);
if (binding == null) {
binding = createCommandSession(mcpSessionId);
sessionBindings.put(mcpSessionId, binding);
logger.debug("Created new command session: MCP={}, Arthas={}", mcpSessionId, binding.getArthasSessionId());
return binding;
}
if (isSessionValid(binding)) {
logger.debug("Using existing valid session: MCP={}, Arthas={}", mcpSessionId, binding.getArthasSessionId());
return binding;
}
logger.info("Session expired, recreating: MCP={}, Arthas={}", mcpSessionId, binding.getArthasSessionId());
try {
commandExecutor.closeSession(binding.getArthasSessionId());
} catch (Exception e) {
logger.debug("Failed to close expired session (may already be cleaned up): {}", e.getMessage());
}
CommandSessionBinding newBinding = createCommandSession(mcpSessionId);
sessionBindings.put(mcpSessionId, newBinding);
logger.info("Recreated command session: MCP={}, Old Arthas={}, New Arthas={}",
mcpSessionId, binding.getArthasSessionId(), newBinding.getArthasSessionId());
return newBinding;
}
/**
* 检查session是否有效
* 通过尝试获取结果来验证session和consumer是否仍然存在
*/
private boolean isSessionValid(CommandSessionBinding binding) {
try {
Map<String, Object> result = commandExecutor.pullResults(binding.getArthasSessionId(), binding.getConsumerId());
if (result == null) {
return true;
}
Boolean success = (Boolean) result.get("success");
if (Boolean.TRUE.equals(success)) {
return true;
}
String errorMessage = (String) result.get("error");
if (errorMessage != null && (errorMessage.contains("Session not found") ||
errorMessage.contains("Consumer not found") ||
errorMessage.contains("session is inactive"))) {
logger.debug("Session validation failed: {}", errorMessage);
return false;
}
return true;
} catch (Exception e) {
logger.debug("Session validation error: {}", e.getMessage());
return false;
}
}
public void closeCommandSession(String mcpSessionId) {
CommandSessionBinding binding = sessionBindings.remove(mcpSessionId);
if (binding != null) {
commandExecutor.closeSession(binding.getArthasSessionId());
logger.debug("Closed command session: MCP={}, Arthas={}", mcpSessionId, binding.getArthasSessionId());
}
}
public void closeAllSessions() {
sessionBindings.keySet().forEach(this::closeCommandSession);
}
}
@@ -13,10 +13,12 @@ import org.slf4j.LoggerFactory;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Stream;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
public class DefaultToolCallback implements ToolCallback {
@@ -62,6 +64,8 @@ public class DefaultToolCallback implements ToolCallback {
validateToolContextSupport(toolContext);
Map<String, Object> toolArguments = extractToolArguments(toolInput);
validateRequiredParameters(toolArguments);
Object[] methodArguments = buildMethodArguments(toolArguments, toolContext);
@@ -85,6 +89,34 @@ public class DefaultToolCallback implements ToolCallback {
}
}
/**
* validate the required parameters
*/
private void validateRequiredParameters(Map<String, Object> toolArguments) {
Parameter[] parameters = this.toolMethod.getParameters();
for (Parameter parameter : parameters) {
if (parameter.getType().isAssignableFrom(ToolContext.class)) {
continue;
}
ToolParam toolParam = parameter.getAnnotation(ToolParam.class);
if (toolParam != null && toolParam.required()) {
String paramName = parameter.getName();
Object paramValue = toolArguments.get(paramName);
// check if the parameter is empty or an empty string
if (paramValue == null) {
throw new IllegalArgumentException("Required parameter '" + paramName + "' is missing");
}
if (paramValue instanceof String && ((String) paramValue).trim().isEmpty()) {
throw new IllegalArgumentException("Required parameter '" + paramName + "' cannot be empty");
}
}
}
}
private Map<String, Object> extractToolArguments(String toolInput) {
return JsonParser.fromJson(toolInput, new TypeReference<Map<String, Object>>() {
});
@@ -31,6 +31,10 @@ public class ToolDefinition {
return inputSchema;
}
public boolean isStreamable() {
return streamable;
}
public static Builder builder() {
return new Builder();
}
@@ -1,54 +0,0 @@
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<String, Object> result = executor.execute(command, timeout);
return JsonParser.toJson(result);
} catch (Exception e) {
logger.error("Failed to execute command: {}", command, e);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("error", true);
errorResponse.put("message", "Error executing command '" + command + "': " + e.getMessage());
return JsonParser.toJson(errorResponse);
}
}
}
@@ -0,0 +1,262 @@
package com.taobao.arthas.mcp.server.tool.function;
import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 同步工具的工具类
* 提供同步执行命令并收集所有结果的功能
*
* @author Yeaury
*/
public final class StreamableToolUtils {
private static final Logger logger = LoggerFactory.getLogger(StreamableToolUtils.class);
private static final int DEFAULT_POLL_INTERVAL_MS = 200; // 默认轮询间隔200ms
private static final int ERROR_RETRY_INTERVAL_MS = 500; // 错误重试间隔500ms
private static final int MAX_POLL_ATTEMPTS = 100; // 最大轮询尝试次数,避免无限循环
private static final int MAX_ERROR_RETRIES = 10; // 最大错误重试次数
public static final int MIN_ALLOW_INPUT_COUNT_TO_COMPLETE = 2;
private StreamableToolUtils() {
}
/**
* 同步执行命令并收集所有结果,支持进度通知
*
* @param exchange MCP交换器,用于发送进度通知
* @param commandContext 命令上下文
* @param expectedResultCount 预期结果数量
* @param intervalMs 轮询间隔
* @param progressToken 进度令牌
* @return 包含所有结果的Map,如果执行失败返回null
*/
public static Map<String, Object> executeAndCollectResults(McpNettyServerExchange exchange,
ArthasCommandContext commandContext,
Integer expectedResultCount, Integer intervalMs,
String progressToken) {
List<Object> allResults = new ArrayList<>();
int pollAttempts = 0;
int errorRetries = 0;
int allowInputCount = 0;
int totalResultCount = 0;
// 轮询间隔使用命令执行间隙的 1/10,事件驱动则在命令中自定义默认轮询间隔
// 工具中默认轮询间隔为200ms
int pullIntervalMs = (intervalMs != null && intervalMs > 0) ? intervalMs : DEFAULT_POLL_INTERVAL_MS;
try {
while (pollAttempts < MAX_POLL_ATTEMPTS) {
pollAttempts++;
try {
Map<String, Object> results = commandContext.pullResults();
if (results == null) {
Thread.sleep(pullIntervalMs);
continue;
}
errorRetries = 0;
Map<String, Object> filteredResults = filterCommandSpecificResults(results);
List<Object> currentBatchResults = getCommandSpecificResults(filteredResults);
if (currentBatchResults != null && !currentBatchResults.isEmpty()) {
allResults.addAll(currentBatchResults);
totalResultCount += currentBatchResults.size();
logger.debug("Collected {} results, total: {}", currentBatchResults.size(), totalResultCount);
if (exchange != null) {
sendProgressNotification(exchange, totalResultCount,
expectedResultCount != null ? expectedResultCount : totalResultCount,
progressToken);
}
}
boolean commandCompleted = checkCommandCompletion(results, allowInputCount);
if (commandCompleted) {
allowInputCount++;
}
String jobStatus = (String) results.get("jobStatus");
// 判断是否应该结束
// 如果是TERMINATED状态,或者命令已完成且允许输入次数大于等于2,或者实际结果数量达到预期结果数量
if ("TERMINATED".equals(jobStatus)
|| (commandCompleted && allowInputCount >= MIN_ALLOW_INPUT_COUNT_TO_COMPLETE)
|| (expectedResultCount != null && totalResultCount >= expectedResultCount)) {
logger.info("Command completed. Total results collected: {}, Expected: {}", totalResultCount, expectedResultCount);
break;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.warn("Command execution interrupted");
return null;
} catch (Exception e) {
if (++errorRetries >= MAX_ERROR_RETRIES) {
logger.error("Maximum error retries exceeded", e);
return null;
}
try {
Thread.sleep(ERROR_RETRY_INTERVAL_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return null;
}
}
}
Map<String, Object> finalResult = new HashMap<>();
finalResult.put("results", allResults);
finalResult.put("resultCount", totalResultCount);
finalResult.put("status", "completed");
finalResult.put("stage", "final");
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
logger.warn("Command execution reached maximum poll attempts: {}", MAX_POLL_ATTEMPTS);
finalResult.put("warning", "Execution reached maximum poll attempts");
}
return finalResult;
} catch (Exception e) {
logger.error("Error in command execution", e);
return null;
}
}
private static boolean checkCommandCompletion(Map<String, Object> results, int currentAllowInputCount) {
if (results == null) {
return false;
}
@SuppressWarnings("unchecked")
List<Object> resultList = (List<Object>) results.get("results");
if (resultList == null || resultList.isEmpty()) {
return false;
}
for (Object result : resultList) {
if (result.getClass().getSimpleName().equals("InputStatusModel")) {
try {
java.lang.reflect.Method getInputStatusMethod = result.getClass().getMethod("getInputStatus");
Object inputStatusObj = getInputStatusMethod.invoke(result);
if (inputStatusObj != null) {
String inputStatusName = inputStatusObj.getClass().getSimpleName().equals("InputStatus") ?
inputStatusObj.toString() : String.valueOf(inputStatusObj);
if ("ALLOW_INPUT".equals(inputStatusName)) {
logger.debug("Command completion detected via InputStatusModel: ALLOW_INPUT (count: {})", currentAllowInputCount + 1);
return true;
}
}
} catch (Exception e) {
logger.debug("Failed to get inputStatus from InputStatusModel", e);
}
}
}
return false;
}
private static Map<String, Object> filterCommandSpecificResults(Map<String, Object> results) {
if (results == null) {
return new HashMap<>();
}
Map<String, Object> filteredResults = new HashMap<>(results);
@SuppressWarnings("unchecked")
List<Object> resultList = (List<Object>) results.get("results");
if (resultList == null || resultList.isEmpty()) {
return filteredResults;
}
// 定义需要排除的辅助模型类型
String[] auxiliaryModelTypes = {
"InputStatusModel", "StatusModel", "WelcomeModel", "MessageModel",
"CommandRequestModel", "SessionModel", "EnhancerModel"
};
List<Object> filteredResultList = resultList.stream()
.filter(result -> {
String resultClassName = result.getClass().getSimpleName();
for (String auxiliaryType : auxiliaryModelTypes) {
if (resultClassName.equals(auxiliaryType)) {
return false;
}
}
return true;
})
.collect(java.util.stream.Collectors.toList());
filteredResults.put("results", filteredResultList);
filteredResults.put("resultCount", filteredResultList.size());
return filteredResults;
}
private static List<Object> getCommandSpecificResults(Map<String, Object> filteredResults) {
if (filteredResults == null) {
return new ArrayList<>();
}
@SuppressWarnings("unchecked")
List<Object> resultList = (List<Object>) filteredResults.get("results");
return resultList != null ? resultList : new ArrayList<>();
}
/**
* 发送进度通知
*/
private static void sendProgressNotification(McpNettyServerExchange exchange, int currentResultCount,
int totalExpected, String progressToken) {
try {
if (progressToken != null) {
exchange.progressNotification(new McpSchema.ProgressNotification(
progressToken,
currentResultCount,
(double) totalExpected
)).join();
}
} catch (Exception e) {
logger.error("Error sending progress notification", e);
}
}
public static Map<String, Object> createErrorResponse(String message) {
Map<String, Object> response = new HashMap<>();
response.put("error", true);
response.put("message", message);
response.put("status", "error");
response.put("stage", "final");
return response;
}
public static Map<String, Object> createCompletedResponse(String message, Map<String, Object> results) {
Map<String, Object> response = new HashMap<>();
response.put("status", "completed");
response.put("message", message);
response.put("stage", "final");
if (results != null) {
response.putAll(results);
}
return response;
}
}
@@ -0,0 +1,81 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
import com.taobao.arthas.mcp.server.util.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.*;
import static com.taobao.arthas.mcp.server.tool.function.StreamableToolUtils.*;
public class DashboardTool {
private static final Logger logger = LoggerFactory.getLogger(DashboardTool.class);
public static final int DEFAULT_NUMBER_OF_EXECUTIONS = 3;
public static final int DEFAULT_REFRESH_INTERVAL_MS = 3000;
/**
* dashboard 实时面板命令
* 支持:
* - intervalMs: 刷新间隔,单位 ms,默认 3000ms
* - count: 刷新次数限制,即 -n 参数;如果不指定则使用 DEFAULT_NUMBER_OF_EXECUTIONS (3次)
*/
@Tool(
name = "dashboard",
description = "Dashboard 诊断工具: 实时展示 JVM/应用面板,可利用参数控制诊断次数与间隔。对应 Arthas 的 dashboard 命令。",
streamable = true
)
public String dashboard(
@ToolParam(description = "刷新间隔,单位为毫秒,默认 3000ms。用于控制输出频率", required = false)
Integer intervalMs,
@ToolParam(description = "执行次数限制,默认值为 3。达到指定次数后自动停止", required = false)
Integer numberOfExecutions,
ToolContext toolContext
) {
McpNettyServerExchange exchange = (McpNettyServerExchange) toolContext.getContext().get(TOOL_CONTEXT_MCP_EXCHANGE_KEY);
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
Object progressTokenObj = toolContext.getContext().get(PROGRESS_TOKEN);
String progressToken = progressTokenObj != null ? String.valueOf(progressTokenObj) : null;
int interval = (intervalMs != null && intervalMs > 0) ? intervalMs : DEFAULT_REFRESH_INTERVAL_MS;
int execCount = (numberOfExecutions != null && numberOfExecutions > 0) ? numberOfExecutions : DEFAULT_NUMBER_OF_EXECUTIONS;
try {
StringBuilder cmd = new StringBuilder("dashboard");
cmd.append(" -i ").append(interval);
cmd.append(" -n ").append(execCount);
String commandStr = cmd.toString();
logger.info("Starting dashboard execution: {}", commandStr);
// 如果只执行一次,使用同步执行
if (execCount == 1) {
logger.info("Executing sync dashboard command: {}", commandStr);
Map<String, Object> result = commandContext.executeSync(commandStr);
return JsonParser.toJson(result);
} else {
Map<String, Object> asyncResult = commandContext.executeAsync(commandStr);
logger.debug("Async execution started: {}", asyncResult);
Map<String, Object> results = executeAndCollectResults(exchange, commandContext, execCount, interval / 10, progressToken);
if (results != null) {
return JsonParser.toJson(createCompletedResponse("Dashboard execution completed successfully", results));
} else {
return JsonParser.toJson(createErrorResponse("Dashboard execution failed due to timeout or error limits exceeded"));
}
}
} catch (Exception e) {
logger.error("Error executing dashboard command", e);
return JsonParser.toJson(createErrorResponse("Error executing dashboard: " + e.getMessage()));
}
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class GetStaticTool {
@@ -25,8 +29,11 @@ public class GetStaticTool {
String fieldName,
@ToolParam(description = "OGNL 表达式", required = false)
String ognlExpression
String ognlExpression,
ToolContext toolContext
) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("getstatic");
if (classLoaderHash != null && !classLoaderHash.trim().isEmpty()) {
@@ -43,6 +50,6 @@ public class GetStaticTool {
}
String commandStr = cmd.toString();
return ArthasCommandExecutor.executeCommand(commandStr);
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
}
@@ -0,0 +1,70 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
import com.taobao.arthas.mcp.server.util.JsonParser;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class HeapdumpTool {
public static final String DEFAULT_DUMP_DIR = Paths.get("arthas-output").toAbsolutePath().toString().replace("\\", "/");
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
/**
* heapdump 诊断工具
* 支持:
* - live: 是否只 dump 存活对象 (--live)
* - filePath: 输出文件路径,若为空则使用默认临时文件
*/
@Tool(
name = "heapdump",
description = "Heapdump 诊断工具: 生成 JVM heap dump,支持 --live 选项。对应 Arthas 的 heapdump 命令。"
)
public String heapdump(
@ToolParam(description = "是否只 dump 存活对象 (--live)", required = false)
Boolean live,
@ToolParam(description = "指定输出文件路径,默认为当前工作目录下的arthas-output文件夹中的时间戳命名的.hprof文件", required = false)
String filePath,
ToolContext toolContext
) throws IOException {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
String finalFilePath;
if (filePath != null && !filePath.trim().isEmpty()) {
finalFilePath = filePath.trim().replace("\\", "/");
} else {
Path defaultDir = Paths.get(DEFAULT_DUMP_DIR);
if (!Files.exists(defaultDir)) {
Files.createDirectories(defaultDir);
}
String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMATTER);
String defaultFileName = String.format("heapdump_%s.hprof", timestamp);
finalFilePath = Paths.get(DEFAULT_DUMP_DIR, defaultFileName).toString().replace("\\", "/");
}
StringBuilder cmd = new StringBuilder("heapdump");
if (Boolean.TRUE.equals(live)) {
cmd.append(" --live");
}
cmd.append(" ").append(finalFilePath);
String commandStr = cmd.toString();
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
}
@@ -1,7 +1,11 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class JvmTool {
@@ -9,8 +13,9 @@ public class JvmTool {
name = "jvm",
description = "Jvm 诊断工具: 查看当前 JVM 运行时信息。对应 Arthas 的 jvm 命令。"
)
public String jvm() {
public String jvm(ToolContext toolContext) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
String commandStr = "jvm";
return ArthasCommandExecutor.executeCommand(commandStr);
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
}
@@ -0,0 +1,123 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
import com.taobao.arthas.mcp.server.util.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.*;
import static com.taobao.arthas.mcp.server.tool.function.StreamableToolUtils.*;
public class MBeanTool {
private static final Logger logger = LoggerFactory.getLogger(MBeanTool.class);
public static final int DEFAULT_NUMBER_OF_EXECUTIONS = 3;
public static final int DEFAULT_REFRESH_INTERVAL_MS = 3000;
/**
* mbean 诊断工具: 查看或监控 MBean 属性
* 支持:
* - namePattern: MBean 名称表达式,支持通配符或正则(需开启 -E)
* - attributePattern: 属性名称表达式,支持通配符或正则(需开启 -E)
* - metadata: 是否查看元信息 (-m)
* - intervalMs: 刷新属性值时间间隔 (ms) (-i),required=false
* - numberOfExecutions: 刷新次数 (-n),若未指定或 <=0 则使用 DEFAULT_NUMBER_OF_EXECUTIONS
* - regex: 是否启用正则匹配 (-E),required=false
*/
@Tool(
name = "mbean",
description = "MBean 诊断工具: 查看或监控 MBean 属性信息,对应 Arthas 的 mbean 命令。",
streamable = true
)
public String mbean(
@ToolParam(description = "MBean名称表达式匹配,如java.lang:type=GarbageCollector,name=*")
String namePattern,
@ToolParam(description = "属性名表达式匹配,支持通配符如CollectionCount", required = false)
String attributePattern,
@ToolParam(description = "是否查看元信息 (-m)", required = false)
Boolean metadata,
@ToolParam(description = "刷新间隔,单位为毫秒,默认 3000ms。用于控制输出频率", required = false)
Integer intervalMs,
@ToolParam(description = "执行次数限制,默认值为 3。达到指定次数后自动停止", required = false)
Integer numberOfExecutions,
@ToolParam(description = "开启正则表达式匹配,默认为通配符匹配,默认false", required = false)
Boolean regex,
ToolContext toolContext
) {
McpNettyServerExchange exchange = (McpNettyServerExchange) toolContext.getContext().get(TOOL_CONTEXT_MCP_EXCHANGE_KEY);
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
Object progressTokenObj = toolContext.getContext().get(PROGRESS_TOKEN);
String progressToken = progressTokenObj != null ? String.valueOf(progressTokenObj) : null;
boolean needStreamOutput = (intervalMs != null && intervalMs > 0) || (numberOfExecutions != null && numberOfExecutions > 0);
int interval = DEFAULT_REFRESH_INTERVAL_MS;
int execCount = DEFAULT_NUMBER_OF_EXECUTIONS;
if (needStreamOutput) {
interval = (intervalMs != null && intervalMs > 0) ? intervalMs : DEFAULT_REFRESH_INTERVAL_MS;
execCount = (numberOfExecutions != null && numberOfExecutions > 0) ? numberOfExecutions : DEFAULT_NUMBER_OF_EXECUTIONS;
}
try {
StringBuilder cmd = new StringBuilder("mbean");
if (Boolean.TRUE.equals(metadata)) {
cmd.append(" -m");
}
if (Boolean.TRUE.equals(regex)) {
cmd.append(" -E");
}
// 只有在需要流式输出且不是查看元数据时才添加 -i 和 -n 参数
if (needStreamOutput && !Boolean.TRUE.equals(metadata)) {
cmd.append(" -i ").append(interval);
cmd.append(" -n ").append(execCount);
}
if (namePattern != null && !namePattern.trim().isEmpty()) {
cmd.append(" ").append(namePattern.trim());
}
if (attributePattern != null && !attributePattern.trim().isEmpty()) {
cmd.append(" ").append(attributePattern.trim());
}
String commandStr = cmd.toString();
logger.info("Starting mbean execution: {}", commandStr);
// 使用同步执行的情况:查看元数据 或者 不需要流式输出
if (Boolean.TRUE.equals(metadata) || !needStreamOutput) {
logger.info("Executing sync mbean command: {}", commandStr);
Map<String, Object> result = commandContext.executeSync(commandStr);
return JsonParser.toJson(result);
} else {
Map<String, Object> asyncResult = commandContext.executeAsync(commandStr);
logger.debug("Async execution started: {}", asyncResult);
Map<String, Object> results = executeAndCollectResults(exchange, commandContext, execCount, interval / 10, progressToken);
if (results != null) {
return JsonParser.toJson(createCompletedResponse("MBean execution completed successfully", results));
} else {
return JsonParser.toJson(createErrorResponse("MBean execution failed due to timeout or error limits exceeded"));
}
}
} catch (Exception e) {
logger.error("Error executing mbean command", e);
return JsonParser.toJson(createErrorResponse("Error executing mbean: " + e.getMessage()));
}
}
}
@@ -1,7 +1,11 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.function.ArthasCommandExecutor;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class MemoryTool {
@@ -9,8 +13,9 @@ public class MemoryTool {
name = "memory",
description = "Memory 诊断工具: 查看 JVM 内存使用情况,对应 Arthas 的 memory 命令。"
)
public String memory() {
public String memory(ToolContext toolContext) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
String commandStr = "memory";
return ArthasCommandExecutor.executeCommand(commandStr);
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class OgnlTool {
@@ -21,8 +25,11 @@ public class OgnlTool {
String classLoaderClass,
@ToolParam(description = "结果对象展开层次 (-x),默认 1", required = false)
Integer expandLevel
Integer expandLevel,
ToolContext toolContext
) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("ognl");
if (classLoaderHash != null && !classLoaderHash.trim().isEmpty()) {
@@ -37,6 +44,6 @@ public class OgnlTool {
cmd.append(" ").append(expression.trim());
String commandStr = cmd.toString();
return ArthasCommandExecutor.executeCommand(commandStr);
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class PerfCounterTool {
@@ -12,13 +16,15 @@ public class PerfCounterTool {
)
public String perfcounter(
@ToolParam(description = "是否打印更多详情 (-d)", required = false)
Boolean detailed
Boolean detailed,
ToolContext toolContext
) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("perfcounter");
if (Boolean.TRUE.equals(detailed)) {
cmd.append(" -d");
}
String commandStr = cmd.toString();
return ArthasCommandExecutor.executeCommand(commandStr);
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class SysEnvTool {
@@ -12,13 +16,15 @@ public class SysEnvTool {
)
public String sysenv(
@ToolParam(description = "环境变量名。若为空或空字符串,则查看所有变量;否则查看单个变量值。", required = false)
String envName
String envName,
ToolContext toolContext
) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("sysenv");
if (envName != null && !envName.trim().isEmpty()) {
cmd.append(" ").append(envName.trim());
}
String commandStr = cmd.toString();
return ArthasCommandExecutor.executeCommand(commandStr);
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class SysPropTool {
@@ -15,8 +19,11 @@ public class SysPropTool {
String propertyName,
@ToolParam(description = "属性值;若指定则修改,否则查看", required = false)
String propertyValue
String propertyValue,
ToolContext toolContext
) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("sysprop");
if (propertyName != null && !propertyName.trim().isEmpty()) {
cmd.append(" ").append(propertyName.trim());
@@ -25,6 +32,6 @@ public class SysPropTool {
}
}
String commandStr = cmd.toString();
return ArthasCommandExecutor.executeCommand(commandStr);
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
}
@@ -0,0 +1,68 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
import com.taobao.arthas.mcp.server.util.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.*;
public class ThreadTool {
private static final Logger logger = LoggerFactory.getLogger(ThreadTool.class);
/**
* thread 诊断工具: 查看线程信息及堆栈
* 支持:
* - threadId: 线程 ID,required=false
* - topN: 最忙前 N 个线程并打印堆栈 (-n),required=false
* - blocking: 是否查找阻塞其他线程的线程 (-b),required=false
* - all: 是否显示所有匹配线程 (--all),required=false
*/
@Tool(
name = "thread",
description = "Thread 诊断工具: 查看线程信息及堆栈,对应 Arthas 的 thread 命令。一次性输出结果。"
)
public String thread(
@ToolParam(description = "线程 ID", required = false)
Long threadId,
@ToolParam(description = "最忙前 N 个线程并打印堆栈 (-n)", required = false)
Integer topN,
@ToolParam(description = "是否查找阻塞其他线程的线程 (-b)", required = false)
Boolean blocking,
@ToolParam(description = "是否显示所有匹配线程 (--all)", required = false)
Boolean all,
ToolContext toolContext
) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("thread");
if (Boolean.TRUE.equals(blocking)) {
cmd.append(" -b");
}
if (topN != null && topN > 0) {
cmd.append(" -n ").append(topN);
}
if (Boolean.TRUE.equals(all)) {
cmd.append(" --all");
}
if (threadId != null && threadId > 0) {
cmd.append(" ").append(threadId);
}
String commandStr = cmd.toString();
logger.info("Executing thread command: {}", commandStr);
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class VMOptionTool {
@@ -15,8 +19,11 @@ public class VMOptionTool {
String key,
@ToolParam(description = "更新值,仅在更新时使用", required = false)
String value
String value,
ToolContext toolContext
) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("vmoption");
if (key != null && !key.trim().isEmpty()) {
cmd.append(" ").append(key.trim());
@@ -25,6 +32,6 @@ public class VMOptionTool {
}
}
String commandStr = cmd.toString();
return ArthasCommandExecutor.executeCommand(commandStr);
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.jvm300;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class VMToolTool {
@@ -37,8 +41,11 @@ public class VMToolTool {
String express,
@ToolParam(description = "线程 ID (-t),interruptThread 时使用", required = false)
Long threadId
Long threadId,
ToolContext toolContext
) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("vmtool");
if (action == null || action.trim().isEmpty()) {
throw new IllegalArgumentException("vmtool: action 参数不能为空");
@@ -76,7 +83,7 @@ public class VMToolTool {
}
String commandStr = cmd.toString();
return ArthasCommandExecutor.executeCommand(commandStr);
return JsonParser.toJson(commandContext.executeSync(commandStr));
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.klass100;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class ClassLoaderTool {
@@ -30,7 +34,10 @@ public class ClassLoaderTool {
String resource,
@ToolParam(description = "要加载的类名,支持全限定名", required = false)
String loadClass) {
String loadClass,
ToolContext toolContext) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("classloader");
@@ -68,6 +75,6 @@ public class ClassLoaderTool {
cmd.append(" --load ").append(loadClass.trim());
}
return ArthasCommandExecutor.executeCommand(cmd.toString());
return JsonParser.toJson(commandContext.executeSync(cmd.toString()));
}
}
@@ -0,0 +1,68 @@
package com.taobao.arthas.mcp.server.tool.function.klass100;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
import com.taobao.arthas.mcp.server.util.JsonParser;
import java.nio.file.Paths;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class DumpClassTool {
/**
* Dump 命令 - 将JVM中已加载的类字节码导出到指定目录
* 适用于批量下载指定包目录的class字节码
*/
@Tool(
name = "dump",
description = "将JVM中实际运行的class字节码dump到指定目录,适用于批量下载指定包目录的class字节码"
)
public String dump(
@ToolParam(description = "类名表达式匹配,如java.lang.String或demo.MathGame")
String classPattern,
@ToolParam(description = "指定输出目录,默认为arthas-output文件夹", required = false)
String outputDir,
@ToolParam(description = "ClassLoader的hashcode(16进制),用于指定特定的ClassLoader", required = false)
String classLoaderHashcode,
@ToolParam(description = "ClassLoader的完整类名,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false)
String classLoaderClass,
@ToolParam(description = "是否包含子类,默认为false", required = false)
Boolean includeInnerClasses,
@ToolParam(description = "限制dump的类数量,避免输出过多文件", required = false)
Integer limit,
ToolContext toolContext) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("dump");
cmd.append(" ").append(classPattern);
if (outputDir != null && !outputDir.trim().isEmpty()) {
cmd.append(" -d ").append(outputDir.trim());
}
if (classLoaderHashcode != null && !classLoaderHashcode.trim().isEmpty()) {
cmd.append(" -c ").append(classLoaderHashcode.trim());
} else if (classLoaderClass != null && !classLoaderClass.trim().isEmpty()) {
cmd.append(" --classLoaderClass ").append(classLoaderClass.trim());
}
if (Boolean.TRUE.equals(includeInnerClasses)) {
cmd.append(" --include-inner-classes");
}
if (limit != null && limit > 0) {
cmd.append(" --limit ").append(limit);
}
return JsonParser.toJson(commandContext.executeSync(cmd.toString()));
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.klass100;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class JadTool {
@@ -30,7 +34,10 @@ public class JadTool {
Boolean useRegex,
@ToolParam(description = "指定dump class文件目录,默认会dump到logback.xml中配置的log目录下", required = false)
String dumpDirectory) {
String dumpDirectory,
ToolContext toolContext) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("jad");
@@ -58,6 +65,6 @@ public class JadTool {
cmd.append(" -d ").append(dumpDirectory.trim());
}
return ArthasCommandExecutor.executeCommand(cmd.toString());
return JsonParser.toJson(commandContext.executeSync(cmd.toString()));
}
}
@@ -1,11 +1,19 @@
package com.taobao.arthas.mcp.server.tool.function.klass100;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import java.nio.file.Paths;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class MemoryCompilerTool {
public static final String DEFAULT_DUMP_DIR = Paths.get("arthas-output").toAbsolutePath().toString();
@Tool(
name = "mc",
description = "Memory Compiler/内存编译器,编译.java文件生成.class"
@@ -20,8 +28,11 @@ public class MemoryCompilerTool {
@ToolParam(description = "ClassLoader的完整类名,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false)
String classLoaderClass,
@ToolParam(description = "指定输出目录", required = false)
String outputDir) {
@ToolParam(description = "指定输出目录,默认为工作目录下arthas-output文件夹", required = false)
String outputDir,
ToolContext toolContext) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("mc");
@@ -35,8 +46,10 @@ public class MemoryCompilerTool {
if (outputDir != null && !outputDir.trim().isEmpty()) {
cmd.append(" -d ").append(outputDir.trim());
}else {
cmd.append(" -d ").append(DEFAULT_DUMP_DIR);
}
return ArthasCommandExecutor.executeCommand(cmd.toString());
return JsonParser.toJson(commandContext.executeSync(cmd.toString()));
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.klass100;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class RedefineTool {
@@ -18,7 +22,10 @@ public class RedefineTool {
String classLoaderHash,
@ToolParam(description = "指定执行表达式的ClassLoader的class name,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false)
String classLoaderClass) {
String classLoaderClass,
ToolContext toolContext) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("redefine");
@@ -30,6 +37,6 @@ public class RedefineTool {
cmd.append(" --classLoaderClass ").append(classLoaderClass.trim());
}
return ArthasCommandExecutor.executeCommand(cmd.toString());
return JsonParser.toJson(commandContext.executeSync(cmd.toString()));
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.klass100;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class RetransformTool {
@@ -18,7 +22,10 @@ public class RetransformTool {
String classLoaderHash,
@ToolParam(description = "ClassLoader的完整类名,如sun.misc.Launcher$AppClassLoader,可替代hashcode", required = false)
String classLoaderClass) {
String classLoaderClass,
ToolContext toolContext) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
StringBuilder cmd = new StringBuilder("retransform");
@@ -30,6 +37,6 @@ public class RetransformTool {
cmd.append(" --classLoaderClass ").append(classLoaderClass.trim());
}
return ArthasCommandExecutor.executeCommand(cmd.toString());
return JsonParser.toJson(commandContext.executeSync(cmd.toString()));
}
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.klass100;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class SearchClassTool {
@@ -11,8 +15,10 @@ public class SearchClassTool {
description = "获取指定类的详细信息,包括类加载器、方法、字段等信息"
)
public String sc(
@ToolParam(description = "要查询的类名,支持全限定名") String className) {
return ArthasCommandExecutor.executeCommand("sc -d " + className);
@ToolParam(description = "要查询的类名,支持全限定名") String className,
ToolContext toolContext) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
return JsonParser.toJson(commandContext.executeSync("sc -d " + className));
}
@@ -1,8 +1,12 @@
package com.taobao.arthas.mcp.server.tool.function.klass100;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
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;
import com.taobao.arthas.mcp.server.util.JsonParser;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.TOOL_CONTEXT_COMMAND_CONTEXT_KEY;
public class SearchMethodTool {
@@ -11,8 +15,10 @@ public class SearchMethodTool {
description = "获取指定类的所有方法信息"
)
public String sm(
@ToolParam(description = "要查询的类名,支持全限定名") String className) {
return ArthasCommandExecutor.executeCommand("sm " + className);
@ToolParam(description = "要查询的类名,支持全限定名") String className,
ToolContext toolContext) {
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
return JsonParser.toJson(commandContext.executeSync("sm " + className));
}
}
@@ -0,0 +1,122 @@
package com.taobao.arthas.mcp.server.tool.function.monitor200;
import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
import com.taobao.arthas.mcp.server.util.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.*;
import static com.taobao.arthas.mcp.server.tool.function.StreamableToolUtils.*;
public class MonitorTool {
private static final Logger logger = LoggerFactory.getLogger(MonitorTool.class);
public static final int DEFAULT_NUMBER_OF_EXECUTIONS = 3;
public static final int DEFAULT_REFRESH_INTERVAL_MS = 3000;
public static final int DEFAULT_MAX_MATCH_COUNT = 50;
/**
* monitor 方法调用监控工具
* 实时监控指定类的指定方法的调用情况
* 支持:
* - classPattern: 类名表达式匹配,支持通配符
* - methodPattern: 方法名表达式匹配,支持通配符
* - condition: OGNL条件表达式,满足条件的调用才会被监控
* - intervalMs: 监控统计输出间隔,默认3000ms
* - numberOfExecutions: 监控执行次数,默认3次
* - regex: 是否开启正则表达式匹配,默认false
* - excludeClassPattern: 排除的类名模式
* - maxMatch: 最大匹配类数量,防止匹配过多类影响性能,默认50
*/
@Tool(
name = "monitor",
description = "Monitor 方法调用监控工具: 实时监控指定类的指定方法的调用情况,包括调用次数、成功次数、失败次数、平均RT、失败率等统计信息。对应 Arthas 的 monitor 命令。",
streamable = true
)
public String monitor(
@ToolParam(description = "类名表达式匹配,支持通配符,如demo.MathGame")
String classPattern,
@ToolParam(description = "方法名表达式匹配,支持通配符,如primeFactors", required = false)
String methodPattern,
@ToolParam(description = "OGNL条件表达式,满足条件的调用才会被监控,如params[0]<0", required = false)
String condition,
@ToolParam(description = "监控统计输出间隔,单位为毫秒,默认 3000ms。用于控制输出频率", required = false)
Integer intervalMs,
@ToolParam(description = "执行次数限制,默认值为3。达到指定次数后自动停止", required = false)
Integer numberOfExecutions,
@ToolParam(description = "开启正则表达式匹配,默认为通配符匹配,默认false", required = false)
Boolean regex,
@ToolParam(description = "最大匹配类数量,防止匹配过多类影响性能,默认50", required = false)
Integer maxMatch,
ToolContext toolContext
) {
McpNettyServerExchange exchange = (McpNettyServerExchange) toolContext.getContext().get(TOOL_CONTEXT_MCP_EXCHANGE_KEY);
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
Object progressTokenObj = toolContext.getContext().get(PROGRESS_TOKEN);
String progressToken = progressTokenObj != null ? String.valueOf(progressTokenObj) : null;
int interval = (intervalMs != null && intervalMs > 0) ? intervalMs : DEFAULT_REFRESH_INTERVAL_MS;
int execCount = (numberOfExecutions != null && numberOfExecutions > 0) ? numberOfExecutions : DEFAULT_NUMBER_OF_EXECUTIONS;
int maxMatchCount = (maxMatch != null && maxMatch > 0) ? maxMatch : DEFAULT_MAX_MATCH_COUNT;
try {
StringBuilder cmd = new StringBuilder("monitor");
// -c 指定统计周期,默认值为 60 秒
cmd.append(" -c ").append(interval / 1000);
cmd.append(" -n ").append(execCount);
cmd.append(" -m ").append(maxMatchCount);
if (Boolean.TRUE.equals(regex)) {
cmd.append(" -E");
}
if (classPattern != null && !classPattern.trim().isEmpty()) {
cmd.append(" ").append(classPattern.trim());
} else {
throw new IllegalArgumentException("classPattern is required");
}
if (methodPattern != null && !methodPattern.trim().isEmpty()) {
cmd.append(" ").append(methodPattern.trim());
}
if (condition != null && !condition.trim().isEmpty()) {
cmd.append(" '").append(condition.trim()).append("'");
}
String commandStr = cmd.toString();
logger.info("Starting monitor execution: {}", commandStr);
Map<String, Object> asyncResult = commandContext.executeAsync(commandStr);
logger.debug("Async execution started: {}", asyncResult);
Map<String, Object> results = executeAndCollectResults(exchange, commandContext, execCount, interval / 10, progressToken);
if (results != null) {
return JsonParser.toJson(createCompletedResponse("Monitor execution completed successfully", results));
} else {
return JsonParser.toJson(createErrorResponse("Monitor execution failed due to timeout or error limits exceeded"));
}
} catch (Exception e) {
logger.error("Error executing monitor command", e);
return JsonParser.toJson(createErrorResponse("Error executing monitor: " + e.getMessage()));
}
}
}
@@ -0,0 +1,106 @@
package com.taobao.arthas.mcp.server.tool.function.monitor200;
import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
import com.taobao.arthas.mcp.server.util.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.*;
import static com.taobao.arthas.mcp.server.tool.function.StreamableToolUtils.*;
public class StackTool {
private static final Logger logger = LoggerFactory.getLogger(StackTool.class);
public static final int DEFAULT_NUMBER_OF_EXECUTIONS = 3;
public static final int DEFAULT_POLL_INTERVAL_MS = 50;
/**
* stack 调用堆栈跟踪工具
* 输出当前方法被调用的调用路径
* 支持:
* - classPattern: 类名表达式匹配,支持通配符
* - methodPattern: 方法名表达式匹配,支持通配符
* - condition: OGNL条件表达式,满足条件的调用才会被跟踪
* - numberOfExecutions: 捕获次数限制,达到指定次数后自动停止
* - regex: 是否开启正则表达式匹配,默认false
* - excludeClassPattern: 排除的类名模式
*/
@Tool(
name = "stack",
description = "Stack 调用堆栈跟踪工具: 输出当前方法被调用的调用路径,帮助分析方法的调用链路。对应 Arthas 的 stack 命令。",
streamable = true
)
public String stack(
@ToolParam(description = "类名表达式匹配,支持通配符,如demo.MathGame")
String classPattern,
@ToolParam(description = "方法名表达式匹配,支持通配符,如primeFactors", required = false)
String methodPattern,
@ToolParam(description = "OGNL条件表达式,满足条件的调用才会被跟踪,如params[0]<0", required = false)
String condition,
@ToolParam(description = "捕获次数限制,默认值为3。达到指定次数后自动停止", required = false)
Integer numberOfExecutions,
@ToolParam(description = "开启正则表达式匹配,默认为通配符匹配,默认false", required = false)
Boolean regex,
ToolContext toolContext
) {
McpNettyServerExchange exchange = (McpNettyServerExchange) toolContext.getContext().get(TOOL_CONTEXT_MCP_EXCHANGE_KEY);
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
Object progressTokenObj = toolContext.getContext().get(PROGRESS_TOKEN);
String progressToken = progressTokenObj != null ? String.valueOf(progressTokenObj) : null;
int execCount = (numberOfExecutions != null && numberOfExecutions > 0) ? numberOfExecutions : DEFAULT_NUMBER_OF_EXECUTIONS;
try {
StringBuilder cmd = new StringBuilder("stack");
cmd.append(" -n ").append(execCount);
if (Boolean.TRUE.equals(regex)) {
cmd.append(" -E");
}
if (classPattern != null && !classPattern.trim().isEmpty()) {
cmd.append(" ").append(classPattern.trim());
} else {
throw new IllegalArgumentException("classPattern is required");
}
if (methodPattern != null && !methodPattern.trim().isEmpty()) {
cmd.append(" ").append(methodPattern.trim());
}
if (condition != null && !condition.trim().isEmpty()) {
cmd.append(" '").append(condition.trim()).append("'");
}
String commandStr = cmd.toString();
logger.info("Starting stack execution: {}", commandStr);
Map<String, Object> asyncResult = commandContext.executeAsync(commandStr);
logger.debug("Async execution started: {}", asyncResult);
Map<String, Object> results = executeAndCollectResults(exchange, commandContext, execCount, DEFAULT_POLL_INTERVAL_MS, progressToken);
if (results != null) {
return JsonParser.toJson(createCompletedResponse("Stack execution completed successfully", results));
} else {
return JsonParser.toJson(createErrorResponse("Stack execution failed due to timeout or error limits exceeded"));
}
} catch (Exception e) {
logger.error("Error executing stack command", e);
return JsonParser.toJson(createErrorResponse("Error executing stack: " + e.getMessage()));
}
}
}
@@ -0,0 +1,258 @@
package com.taobao.arthas.mcp.server.tool.function.monitor200;
import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
import com.taobao.arthas.mcp.server.util.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.*;
import static com.taobao.arthas.mcp.server.tool.function.StreamableToolUtils.*;
public class TimeTunnelTool {
private static final Logger logger = LoggerFactory.getLogger(TimeTunnelTool.class);
public static final int DEFAULT_NUMBER_OF_EXECUTIONS = 3;
public static final int DEFAULT_POLL_INTERVAL_MS = 100;
public static final int DEFAULT_MAX_MATCH_COUNT = 50;
/**
* tt 时空隧道工具 (TimeTunnel)
* 方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下调用进行观测
*/
@Tool(
name = "tt",
description = "TimeTunnel 时空隧道工具: 方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,对应 Arthas 的 tt 命令。支持记录、列表、搜索、查看详情、重放、删除等操作。",
streamable = true
)
public String timeTunnel(
@ToolParam(description = "操作类型: record/t(记录), list/l(列表), search/s(搜索), info/i(查看详情), replay/p(重放), delete/d(删除), deleteAll/da(删除所有),默认record")
String action,
@ToolParam(description = "类名表达式匹配,支持通配符,如demo.MathGame或*Test。record操作时必需", required = false)
String classPattern,
@ToolParam(description = "方法名表达式匹配,支持通配符,如primeFactors或*method。record操作时必需", required = false)
String methodPattern,
@ToolParam(description = "OGNL条件表达式,满足条件的调用才会被记录,如params[0]<0或'params.length==1'", required = false)
String condition,
@ToolParam(description = "记录次数限制,默认值为 3。达到指定次数后自动停止(仅record操作)", required = false)
Integer numberOfExecutions,
@ToolParam(description = "开启正则表达式匹配,默认为通配符匹配,默认false", required = false)
Boolean regex,
@ToolParam(description = "指定索引,用于info/replay/delete等操作", required = false)
Integer index,
@ToolParam(description = "搜索表达式,用于search操作,支持OGNL表达式如'method.name==\"primeFactors\"'", required = false)
String searchExpression,
@ToolParam(description = "Class最大匹配数量,防止匹配到的Class数量太多导致JVM挂起,默认50", required = false)
Integer maxMatchCount,
ToolContext toolContext
) {
McpNettyServerExchange exchange = (McpNettyServerExchange) toolContext.getContext().get(TOOL_CONTEXT_MCP_EXCHANGE_KEY);
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
Object progressTokenObj = toolContext.getContext().get(PROGRESS_TOKEN);
String progressToken = progressTokenObj != null ? String.valueOf(progressTokenObj) : null;
String ttAction = normalizeAction(action);
int execCount = (numberOfExecutions != null && numberOfExecutions > 0) ? numberOfExecutions : DEFAULT_NUMBER_OF_EXECUTIONS;
int maxMatch = (maxMatchCount != null && maxMatchCount > 0) ? maxMatchCount : DEFAULT_MAX_MATCH_COUNT;
validateParameters(ttAction, classPattern, methodPattern, index, searchExpression);
try {
StringBuilder cmd = new StringBuilder("tt");
switch (ttAction) {
case "record":
cmd = buildRecordCommand(cmd, classPattern, methodPattern, condition, execCount, maxMatch, regex);
break;
case "list":
cmd = buildListCommand(cmd, searchExpression);
break;
case "info":
cmd.append(" -i ").append(index);
break;
case "search":
cmd.append(" -s '").append(searchExpression.trim()).append("'");
break;
case "replay":
cmd.append(" -i ").append(index).append(" -p");
break;
case "delete":
cmd.append(" -i ").append(index).append(" -d");
break;
case "deleteall":
cmd.append(" --delete-all");
break;
default:
throw new IllegalArgumentException("Unsupported action: " + ttAction +
". Supported actions: record(t), list(l), info(i), search(s), replay(p), delete(d), deleteAll(da)");
}
String commandStr = cmd.toString();
logger.info("Starting TimeTunnel execution: {}", commandStr);
if ("record".equals(ttAction)) {
Map<String, Object> asyncResult = commandContext.executeAsync(commandStr);
logger.debug("Async execution started: {}", asyncResult);
Map<String, Object> results = executeAndCollectResults(exchange, commandContext, execCount, DEFAULT_POLL_INTERVAL_MS, progressToken);
if (results != null) {
return JsonParser.toJson(createCompletedResponse("TimeTunnel recording completed successfully", results));
} else {
return JsonParser.toJson(createErrorResponse("TimeTunnel recording failed due to timeout or error limits exceeded"));
}
} else {
// list/info/search/replay/delete/deleteAll:使用同步执行,直接返回结果
logger.info("Executing sync tt command: {}", commandStr);
Map<String, Object> result = commandContext.executeSync(commandStr);
return JsonParser.toJson(result);
}
} catch (Exception e) {
logger.error("Error executing tt command", e);
return JsonParser.toJson(createErrorResponse("Error executing tt: " + e.getMessage()));
}
}
/**
* 验证参数
*/
private void validateParameters(String action, String classPattern, String methodPattern,
Integer index, String searchExpression) {
switch (action) {
case "record":
if (classPattern == null || classPattern.trim().isEmpty()) {
throw new IllegalArgumentException("classPattern is required for record operation");
}
if (methodPattern == null || methodPattern.trim().isEmpty()) {
throw new IllegalArgumentException("methodPattern is required for record operation");
}
break;
case "info":
case "replay":
if (index == null) {
throw new IllegalArgumentException(action + " operation requires index parameter");
}
break;
case "search":
if (searchExpression == null || searchExpression.trim().isEmpty()) {
throw new IllegalArgumentException("search operation requires searchExpression parameter");
}
break;
case "delete":
if (index == null) {
throw new IllegalArgumentException("delete operation requires index parameter");
}
break;
case "list":
case "deleteall":
break;
default:
throw new IllegalArgumentException("Unsupported action: " + action);
}
}
/**
* 标准化操作类型
*/
private String normalizeAction(String action) {
if (action == null || action.trim().isEmpty()) {
return "record";
}
String normalizedAction = action.trim().toLowerCase();
switch (normalizedAction) {
case "record":
case "r":
case "-t":
case "t":
return "record";
case "list":
case "l":
case "-l":
return "list";
case "info":
case "i":
case "-i":
return "info";
case "search":
case "s":
case "-s":
return "search";
case "replay":
case "p":
case "-p":
return "replay";
case "delete":
case "d":
case "-d":
return "delete";
case "deleteall":
case "da":
case "--delete-all":
return "deleteall";
default:
return normalizedAction;
}
}
private StringBuilder buildRecordCommand(StringBuilder cmd, String classPattern, String methodPattern,
String condition, int execCount, int maxMatch, Boolean regex) {
cmd.append(" -t");
cmd.append(" -n ").append(execCount);
cmd.append(" -m ").append(maxMatch);
if (Boolean.TRUE.equals(regex)) {
cmd.append(" -E");
}
cmd.append(" '").append(classPattern.trim()).append("'");
cmd.append(" '").append(methodPattern.trim()).append("'");
if (condition != null && !condition.trim().isEmpty()) {
cmd.append(" '").append(condition.trim()).append("'");
}
return cmd;
}
private StringBuilder buildListCommand(StringBuilder cmd, String searchExpression) {
cmd.append(" -l");
if (searchExpression != null && !searchExpression.trim().isEmpty()) {
cmd.append(" '").append(searchExpression.trim()).append("'");
}
return cmd;
}
}
@@ -0,0 +1,105 @@
package com.taobao.arthas.mcp.server.tool.function.monitor200;
import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
import com.taobao.arthas.mcp.server.util.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.*;
import static com.taobao.arthas.mcp.server.tool.function.StreamableToolUtils.*;
public class TraceTool {
private static final Logger logger = LoggerFactory.getLogger(TraceTool.class);
public static final int DEFAULT_NUMBER_OF_EXECUTIONS = 3;
public static final int DEFAULT_POLL_INTERVAL_MS = 100;
public static final int DEFAULT_MAX_MATCH_COUNT = 50;
/**
* trace 方法内部调用路径跟踪工具
* 追踪方法内部调用路径,输出每个节点的耗时信息
*/
@Tool(
name = "trace",
description = "Trace 方法内部调用路径跟踪工具: 追踪方法内部调用路径,输出每个节点的耗时信息,对应 Arthas 的 trace 命令。",
streamable = true
)
public String trace(
@ToolParam(description = "类名表达式匹配,支持通配符,如demo.MathGame")
String classPattern,
@ToolParam(description = "方法名表达式匹配,支持通配符,如primeFactors", required = false)
String methodPattern,
@ToolParam(description = "OGNL条件表达式,包括#cost耗时过滤,如'#cost>100'表示耗时超过100ms", required = false)
String condition,
@ToolParam(description = "执行次数限制,默认值为3。达到指定次数后自动停止", required = false)
Integer numberOfExecutions,
@ToolParam(description = "开启正则表达式匹配,默认为通配符匹配,默认false", required = false)
Boolean regex,
@ToolParam(description = "指定Class最大匹配数量,默认50", required = false)
Integer maxMatchCount,
ToolContext toolContext
) {
McpNettyServerExchange exchange = (McpNettyServerExchange) toolContext.getContext().get(TOOL_CONTEXT_MCP_EXCHANGE_KEY);
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
Object progressTokenObj = toolContext.getContext().get(PROGRESS_TOKEN);
String progressToken = progressTokenObj != null ? String.valueOf(progressTokenObj) : null;
int execCount = (numberOfExecutions != null && numberOfExecutions > 0) ? numberOfExecutions : DEFAULT_NUMBER_OF_EXECUTIONS;
int maxMatch = (maxMatchCount != null && maxMatchCount > 0) ? maxMatchCount : DEFAULT_MAX_MATCH_COUNT;
try {
StringBuilder cmd = new StringBuilder("trace");
cmd.append(" -n ").append(execCount);
cmd.append(" -m ").append(maxMatch);
if (Boolean.TRUE.equals(regex)) {
cmd.append(" -E");
}
if (classPattern != null && !classPattern.trim().isEmpty()) {
cmd.append(" ").append(classPattern.trim());
} else {
throw new IllegalArgumentException("classPattern is required");
}
if (methodPattern != null && !methodPattern.trim().isEmpty()) {
cmd.append(" ").append(methodPattern.trim());
}
if (condition != null && !condition.trim().isEmpty()) {
cmd.append(" '").append(condition.trim()).append("'");
}
String commandStr = cmd.toString();
logger.info("Starting trace execution: {}", commandStr);
Map<String, Object> asyncResult = commandContext.executeAsync(commandStr);
logger.debug("Async execution started: {}", asyncResult);
Map<String, Object> results = executeAndCollectResults(exchange, commandContext, execCount, DEFAULT_POLL_INTERVAL_MS, progressToken);
if (results != null) {
return JsonParser.toJson(createCompletedResponse("Trace execution completed successfully", results));
} else {
return JsonParser.toJson(createErrorResponse("Trace execution failed due to timeout or error limits exceeded"));
}
} catch (Exception e) {
logger.error("Error executing trace command", e);
return JsonParser.toJson(createErrorResponse("Error executing trace: " + e.getMessage()));
}
}
}
@@ -0,0 +1,138 @@
package com.taobao.arthas.mcp.server.tool.function.monitor200;
import com.taobao.arthas.mcp.server.protocol.server.McpNettyServerExchange;
import com.taobao.arthas.mcp.server.session.ArthasCommandContext;
import com.taobao.arthas.mcp.server.tool.ToolContext;
import com.taobao.arthas.mcp.server.tool.annotation.Tool;
import com.taobao.arthas.mcp.server.tool.annotation.ToolParam;
import com.taobao.arthas.mcp.server.util.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import static com.taobao.arthas.mcp.server.tool.util.McpToolUtils.*;
import static com.taobao.arthas.mcp.server.tool.function.StreamableToolUtils.*;
public class WatchTool {
private static final Logger logger = LoggerFactory.getLogger(WatchTool.class);
public static final int DEFAULT_NUMBER_OF_EXECUTIONS = 3;
public static final int DEFAULT_POLL_INTERVAL_MS = 50;
public static final int DEFAULT_MAX_MATCH_COUNT = 50;
public static final int DEFAULT_EXPAND_LEVEL = 1;
public static final String DEFAULT_EXPRESS = "{params, target, returnObj}";
/**
* watch 方法执行观察工具
* 观察指定方法的调用情况,包括入参、返回值和抛出异常等信息
* 整合了完整的参数支持和流式输出能力
*/
@Tool(
name = "watch",
description = "Watch 方法执行观察工具: 观察指定方法的调用情况,包括入参、返回值和抛出异常等信息,支持实时流式输出。对应 Arthas 的 watch 命令。",
streamable = true
)
public String watch(
@ToolParam(description = "类名表达式匹配,支持通配符,如demo.MathGame")
String classPattern,
@ToolParam(description = "方法名表达式匹配,支持通配符,如primeFactors", required = false)
String methodPattern,
@ToolParam(description = "观察表达式,默认为{params, target, returnObj},支持OGNL表达式", required = false)
String express,
@ToolParam(description = "OGNL条件表达式,满足条件的调用才会被观察,如params[0]<0", required = false)
String condition,
@ToolParam(description = "在方法调用之前观察(-b),默认false", required = false)
Boolean beforeMethod,
@ToolParam(description = "在方法抛出异常后观察(-e),默认false", required = false)
Boolean exceptionOnly,
@ToolParam(description = "在方法正常返回后观察(-s),默认false", required = false)
Boolean successOnly,
@ToolParam(description = "执行次数限制,默认值为 3。达到指定次数后自动停止", required = false)
Integer numberOfExecutions,
@ToolParam(description = "开启正则表达式匹配,默认为通配符匹配,默认false", required = false)
Boolean regex,
@ToolParam(description = "指定Class最大匹配数量,默认50", required = false)
Integer maxMatchCount,
@ToolParam(description = "指定输出结果的属性遍历深度,默认1,最大4", required = false)
Integer expandLevel,
ToolContext toolContext
) {
McpNettyServerExchange exchange = (McpNettyServerExchange) toolContext.getContext().get(TOOL_CONTEXT_MCP_EXCHANGE_KEY);
ArthasCommandContext commandContext = (ArthasCommandContext) toolContext.getContext().get(TOOL_CONTEXT_COMMAND_CONTEXT_KEY);
Object progressTokenObj = toolContext.getContext().get(PROGRESS_TOKEN);
String progressToken = progressTokenObj != null ? String.valueOf(progressTokenObj) : null;
int execCount = (numberOfExecutions != null && numberOfExecutions > 0) ? numberOfExecutions : DEFAULT_NUMBER_OF_EXECUTIONS;
int maxMatch = (maxMatchCount != null && maxMatchCount > 0) ? maxMatchCount : DEFAULT_MAX_MATCH_COUNT;
int expandDepth = (expandLevel != null && expandLevel >= 1 && expandLevel <= 4) ? expandLevel : DEFAULT_EXPAND_LEVEL;
String watchExpress = (express != null && !express.trim().isEmpty()) ? express.trim() : DEFAULT_EXPRESS;
try {
StringBuilder cmd = new StringBuilder("watch");
cmd.append(" -n ").append(execCount);
cmd.append(" -m ").append(maxMatch);
cmd.append(" -x ").append(expandDepth);
if (Boolean.TRUE.equals(regex)) {
cmd.append(" -E");
}
if (Boolean.TRUE.equals(beforeMethod)) {
cmd.append(" -b");
} else if (Boolean.TRUE.equals(exceptionOnly)) {
cmd.append(" -e");
} else if (Boolean.TRUE.equals(successOnly)) {
cmd.append(" -s");
} else {
cmd.append(" -f");
}
if (classPattern != null && !classPattern.trim().isEmpty()) {
cmd.append(" ").append(classPattern.trim());
} else {
throw new IllegalArgumentException("classPattern is required");
}
if (methodPattern != null && !methodPattern.trim().isEmpty()) {
cmd.append(" ").append(methodPattern.trim());
}
cmd.append(" '").append(watchExpress).append("'");
if (condition != null && !condition.trim().isEmpty()) {
cmd.append(" '").append(condition.trim()).append("'");
}
String commandStr = cmd.toString();
logger.info("Starting watch execution: {}", commandStr);
Map<String, Object> asyncResult = commandContext.executeAsync(commandStr);
logger.debug("Async execution started: {}", asyncResult);
Map<String, Object> results = executeAndCollectResults(exchange, commandContext, execCount, DEFAULT_POLL_INTERVAL_MS, progressToken);
if (results != null) {
return JsonParser.toJson(createCompletedResponse("Watch execution completed successfully", results));
} else {
return JsonParser.toJson(createErrorResponse("Watch execution failed due to timeout or error limits exceeded"));
}
} catch (Exception e) {
logger.error("Error executing watch command", e);
return JsonParser.toJson(createErrorResponse("Error executing watch: " + e.getMessage()));
}
}
}
@@ -45,6 +45,11 @@ public final class JsonSchemaGenerator {
Parameter[] parameters = method.getParameters();
for (Parameter parameter : parameters) {
ToolParam toolParam = parameter.getAnnotation(ToolParam.class);
if (toolParam == null) {
continue;
}
String paramName = getParameterName(parameter);
Class<?> paramType = parameter.getType();
@@ -1,85 +1,148 @@
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.server.McpStatelessServerFeatures;
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.*;
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";
public static final String TOOL_CONTEXT_COMMAND_CONTEXT_KEY = "commandContext";
public static final String MCP_TRANSPORT_CONTEXT = "mcpTransportContext";
public static final String PROGRESS_TOKEN = "progressToken";
private McpToolUtils() {
}
public static List<McpServerFeatures.ToolSpecification> toToolSpecifications(
List<ToolCallback> tools,
McpServerProperties serverProperties) {
public static List<McpServerFeatures.ToolSpecification> toStreamableToolSpecifications(
List<ToolCallback> tools) {
// 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());
}
if (tools == null || tools.isEmpty()) {
return Collections.emptyList();
}
public static McpServerFeatures.ToolSpecification toToolSpecification(ToolCallback toolCallback) {
// De-duplicate tools by their name, keeping the first occurrence of each tool name
return tools.stream()
.filter(Objects::nonNull)
.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()
.filter(McpToolUtils::isStreamableTool)
.map(McpToolUtils::toToolSpecification)
.collect(Collectors.toList());
}
McpSchema.Tool tool = new McpSchema.Tool(toolCallback.getToolDefinition().getName(),
toolCallback.getToolDefinition().getDescription(), toolCallback.getToolDefinition().getInputSchema());
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<McpNettyServerExchange, Map<String, Object>, CompletableFuture<McpSchema.CallToolResult>>() {
McpServerFeatures.ToolCallFunction callFunction = (exchange, commandContext, request) -> {
try {
Map<String, Object> contextMap = new HashMap<>();
contextMap.put(TOOL_CONTEXT_MCP_EXCHANGE_KEY, exchange);
contextMap.put(TOOL_CONTEXT_COMMAND_CONTEXT_KEY, commandContext);
contextMap.put(PROGRESS_TOKEN, request.getMeta().get(PROGRESS_TOKEN));
ToolContext toolContext = new ToolContext(contextMap);
@Override
public CompletableFuture<McpSchema.CallToolResult> apply(McpNettyServerExchange exchange, Map<String, Object> request) {
try {
Map<String, Object> contextMap = new HashMap<>();
contextMap.put(TOOL_CONTEXT_MCP_EXCHANGE_KEY, exchange);
ToolContext toolContext = new ToolContext(contextMap);
String requestJson = convertParametersToString(request.getArguments());
String callResult = toolCallback.call(convertRequestToString(request), toolContext);
String callResult = toolCallback.call(requestJson, toolContext);
return CompletableFuture.completedFuture(createSuccessResult(callResult));
} catch (Exception e) {
return CompletableFuture.completedFuture(createErrorResult(e.getMessage()));
}
};
return new McpServerFeatures.ToolSpecification(tool, callFunction);
}
List<McpSchema.Content> contents = new ArrayList<>();
contents.add(new McpSchema.TextContent(callResult));
return CompletableFuture.completedFuture(new McpSchema.CallToolResult(contents, false));
}
catch (Exception e) {
List<McpSchema.Content> contents = new ArrayList<>();
contents.add(new McpSchema.TextContent(e.getMessage()));
public static List<McpStatelessServerFeatures.ToolSpecification> toStatelessToolSpecifications(List<ToolCallback> providerToolCallbacks) {
if (providerToolCallbacks == null || providerToolCallbacks.isEmpty()) {
return Collections.emptyList();
}
return CompletableFuture.completedFuture(new McpSchema.CallToolResult(contents, true));
}
}
// De-duplicate tools by their name, keeping the first occurrence of each tool name
return providerToolCallbacks.stream()
.filter(Objects::nonNull)
.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::toStatelessToolSpecification)
.collect(Collectors.toList());
}
private String convertRequestToString(Map<String, Object> request) {
if (request == null) {
return "";
}
try {
return new ObjectMapper().writeValueAsString(request);
} catch (Exception e) {
return request.toString();
}
}
});
}
public static McpStatelessServerFeatures.ToolSpecification toStatelessToolSpecification(ToolCallback toolCallback) {
McpSchema.Tool tool = new McpSchema.Tool(
toolCallback.getToolDefinition().getName(),
toolCallback.getToolDefinition().getDescription(),
toolCallback.getToolDefinition().getInputSchema()
);
McpStatelessServerFeatures.ToolCallFunction callFunction = (context, commandContext, arguments) -> {
try {
Map<String, Object> contextMap = new HashMap<>();
contextMap.put(MCP_TRANSPORT_CONTEXT, context);
contextMap.put(TOOL_CONTEXT_COMMAND_CONTEXT_KEY, commandContext);
ToolContext toolContext = new ToolContext(contextMap);
String argumentsJson = convertParametersToString(arguments);
String callResult = toolCallback.call(argumentsJson, toolContext);
return CompletableFuture.completedFuture(createSuccessResult(callResult));
} catch (Exception e) {
return CompletableFuture.completedFuture(createErrorResult("Error executing tool: " + e.getMessage()));
}
};
return new McpStatelessServerFeatures.ToolSpecification(tool, callFunction);
}
public static boolean isStreamableTool(ToolCallback toolCallback) {
return toolCallback.getToolDefinition().isStreamable();
}
private static String convertParametersToString(Map<String, Object> parameters) {
if (parameters == null) {
return "";
}
try {
return new ObjectMapper().writeValueAsString(parameters);
} catch (Exception e) {
return parameters.toString();
}
}
private static McpSchema.CallToolResult createSuccessResult(String content) {
List<McpSchema.Content> contents = new ArrayList<>();
contents.add(new McpSchema.TextContent(content));
return new McpSchema.CallToolResult(contents, false);
}
private static McpSchema.CallToolResult createErrorResult(String errorMessage) {
List<McpSchema.Content> contents = new ArrayList<>();
contents.add(new McpSchema.TextContent(errorMessage));
return new McpSchema.CallToolResult(contents, true);
}
}
@@ -6,6 +6,7 @@ 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 com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.lang.reflect.Type;
import java.math.BigDecimal;
@@ -18,6 +19,8 @@ public final class JsonParser {
private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
private JsonParser() {
@@ -0,0 +1,209 @@
/**
* Copyright 2025 - 2025 the original author or authors.
*/
package com.taobao.arthas.mcp.server.util;
import com.fasterxml.jackson.core.type.TypeReference;
import com.taobao.arthas.mcp.server.protocol.spec.McpSchema;
import com.taobao.arthas.mcp.server.protocol.spec.McpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.Collection;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
/**
* A utility class for scheduling regular keep-alive calls to maintain connections. It
* sends periodic keep-alive, ping, messages to connected mcp clients to prevent idle
* timeouts.
*
* The pings are sent to all active mcp sessions at regular intervals.
*
*/
public class KeepAliveScheduler {
private static final Logger logger = LoggerFactory.getLogger(KeepAliveScheduler.class);
private static final TypeReference<Object> OBJECT_TYPE_REF = new TypeReference<Object>() {
};
/** Initial delay before the first keepAlive call */
private final Duration initialDelay;
/** Interval between subsequent keepAlive calls */
private final Duration interval;
/** The scheduler used for executing keepAlive calls */
private final ScheduledExecutorService scheduler;
/** Whether this scheduler owns the executor and should shut it down */
private final boolean ownsExecutor;
/** The current state of the scheduler */
private final AtomicBoolean isRunning = new AtomicBoolean(false);
/** The current scheduled task */
private volatile ScheduledFuture<?> currentTask;
/** Supplier for McpSession instances */
private final Supplier<? extends Collection<? extends McpSession>> mcpSessions;
/**
* Creates a KeepAliveScheduler with a custom scheduler, initial delay, interval and a
* supplier for McpSession instances.
* @param scheduler The scheduler to use for executing keepAlive calls
* @param ownsExecutor Whether this scheduler owns the executor and should shut it down
* @param initialDelay Initial delay before the first keepAlive call
* @param interval Interval between subsequent keepAlive calls
* @param mcpSessions Supplier for McpSession instances
*/
private KeepAliveScheduler(ScheduledExecutorService scheduler, boolean ownsExecutor, Duration initialDelay,
Duration interval, Supplier<? extends Collection<? extends McpSession>> mcpSessions) {
this.scheduler = scheduler;
this.ownsExecutor = ownsExecutor;
this.initialDelay = initialDelay;
this.interval = interval;
this.mcpSessions = mcpSessions;
}
public static Builder builder(Supplier<? extends Collection<? extends McpSession>> mcpSessions) {
return new Builder(mcpSessions);
}
/**
* Starts regular keepAlive calls with sessions supplier.
* @return This scheduler instance for method chaining
*/
public KeepAliveScheduler start() {
if (this.isRunning.compareAndSet(false, true)) {
logger.debug("Starting KeepAlive scheduler with initial delay: {}ms, interval: {}ms",
initialDelay.toMillis(), interval.toMillis());
this.currentTask = this.scheduler.scheduleAtFixedRate(
this::sendKeepAlivePings,
this.initialDelay.toMillis(),
this.interval.toMillis(),
TimeUnit.MILLISECONDS
);
return this;
} else {
throw new IllegalStateException("KeepAlive scheduler is already running. Stop it first.");
}
}
/**
* Sends keep-alive pings to all active sessions.
*/
private void sendKeepAlivePings() {
try {
Collection<? extends McpSession> sessions = this.mcpSessions.get();
if (sessions == null || sessions.isEmpty()) {
logger.trace("No active sessions to ping");
return;
}
logger.trace("Sending keep-alive pings to {} sessions", sessions.size());
for (McpSession session : sessions) {
try {
session.sendRequest(McpSchema.METHOD_PING, null, OBJECT_TYPE_REF)
.whenComplete((result, error) -> {
if (error != null) {
logger.warn("Failed to send keep-alive ping to session {}: {}",
session, error.getMessage());
} else {
logger.trace("Keep-alive ping sent successfully to session {}", session);
}
});
} catch (Exception e) {
logger.warn("Exception while sending keep-alive ping to session {}: {}",
session, e.getMessage());
}
}
} catch (Exception e) {
logger.error("Error during keep-alive ping cycle", e);
}
}
public void stop() {
if (this.currentTask != null && !this.currentTask.isCancelled()) {
this.currentTask.cancel(false);
logger.debug("KeepAlive scheduler stopped");
}
this.isRunning.set(false);
}
public boolean isRunning() {
return this.isRunning.get();
}
public void shutdown() {
stop();
if (this.ownsExecutor && !this.scheduler.isShutdown()) {
this.scheduler.shutdown();
try {
if (!this.scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
this.scheduler.shutdownNow();
}
} catch (InterruptedException e) {
this.scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
logger.debug("KeepAlive scheduler executor shut down");
}
}
public static class Builder {
private ScheduledExecutorService scheduler;
private boolean ownsExecutor = false;
private Duration initialDelay = Duration.ofSeconds(0);
private Duration interval = Duration.ofSeconds(30);
private Supplier<? extends Collection<? extends McpSession>> mcpSessions;
Builder(Supplier<? extends Collection<? extends McpSession>> mcpSessions) {
Assert.notNull(mcpSessions, "McpSessions supplier must not be null");
this.mcpSessions = mcpSessions;
}
public Builder scheduler(ScheduledExecutorService scheduler) {
Assert.notNull(scheduler, "Scheduler must not be null");
this.scheduler = scheduler;
this.ownsExecutor = false;
return this;
}
public Builder initialDelay(Duration initialDelay) {
Assert.notNull(initialDelay, "Initial delay must not be null");
this.initialDelay = initialDelay;
return this;
}
public Builder interval(Duration interval) {
Assert.notNull(interval, "Interval must not be null");
this.interval = interval;
return this;
}
public KeepAliveScheduler build() {
if (this.scheduler == null) {
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "mcp-keep-alive-scheduler");
t.setDaemon(true);
return t;
});
this.ownsExecutor = true;
}
return new KeepAliveScheduler(scheduler, ownsExecutor, initialDelay, interval, mcpSessions);
}
}
}