chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>app-platform-mcp-adapter</artifactId>
<name>app-platform-mcp-adapter</name>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-spec</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-sdk</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,46 @@
package com.xspaceagi.mcp.adapter.application;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.xspaceagi.mcp.adapter.dto.McpPageQueryDto;
import com.xspaceagi.mcp.sdk.dto.McpDto;
import com.xspaceagi.system.sdk.service.dto.UserAccessKeyDto;
import java.util.List;
import java.util.Map;
public interface McpConfigApplicationService {
void addMcp(McpDto mcpDto);
void updateMcp(McpDto mcpDto);
void deleteMcp(Long id);
McpDto getMcp(Long id);
McpDto getDeployedMcp(Long id);
List<McpDto> queryMcpListBySpaceId(Long spaceId);
IPage<McpDto> queryDeployedMcpList(McpPageQueryDto mcpPageQueryDto);
IPage<McpDto> queryDeployedMcpListForManage(McpPageQueryDto mcpPageQueryDto);
String getExportMcpServerConfig(Long userId, Long mcpId, UserAccessKeyDto.UserAccessKeyConfig userAccessKeyConfig);
String refreshExportMcpServerConfig(Long userId, Long mcpId);
//生态市场使用
Long deployOfficialMcp(McpDto mcpDto);
Long deployProxyMcp(McpDto mcpDto);
void stopOfficialMcp(Long id);
/**
* 统计 MCP 总数
*
* @return MCP 总数
*/
Long countTotalMcps();
}

View File

@@ -0,0 +1,10 @@
package com.xspaceagi.mcp.adapter.application;
import com.xspaceagi.mcp.sdk.dto.McpDto;
public interface McpDeployTaskService {
void addDeployTask(McpDto mcpDto);
void addDeployTask(McpDto mcpDto, boolean notify);
}

View File

@@ -0,0 +1,38 @@
package com.xspaceagi.mcp.adapter.domain;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xspaceagi.mcp.adapter.dto.McpPageQueryDto;
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
import java.util.List;
public interface McpConfigDomainService {
void add(McpConfig mcpConfig);
void delete(Long id);
void update(McpConfig mcpConfig);
McpConfig getById(Long id);
McpConfig getBySpaceIdAndUid(Long spaceId, String uid);
List<McpConfig> queryListBySpaceId(Long spaceId);
Page<McpConfig> queryDeployedMcpList(McpPageQueryDto mcpPageQueryDto);
Page<McpConfig> queryDeployedMcpListForManage(McpPageQueryDto mcpPageQueryDto);
IPage<McpConfig> queryPage(McpConfig entityFilter, int pageNum, int pageSize);
List<McpConfig> queryDeployedSSEMcpConfigList(Long lastId, int size);
/**
* 统计 MCP 总数
*
* @return MCP 总数
*/
Long countTotalMcps();
}

View File

@@ -0,0 +1,28 @@
package com.xspaceagi.mcp.adapter.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
public class McpPageQueryDto {
@Schema(description = "页码")
private Integer page;
@Schema(description = "每页数量")
private Integer pageSize;
@Schema(description = "关键字搜索")
private String kw;
@Schema(description = "空间ID可选需要通过空间过滤时有用不传则返回官方MCP服务")
private Long spaceId;
@Schema(description = "创建人ID列表")
private List<Long> creatorIds;
@Schema(description = "只返回用户自定义的MCP服务")
private Boolean justReturnSpaceData;
}

View File

@@ -0,0 +1,7 @@
package com.xspaceagi.mcp.adapter.repository;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
public interface McpConfigRepository extends IService<McpConfig> {
}

View File

@@ -0,0 +1,47 @@
package com.xspaceagi.mcp.adapter.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.xspaceagi.mcp.sdk.enums.DeployStatusEnum;
import com.xspaceagi.mcp.sdk.enums.InstallTypeEnum;
import lombok.Data;
import java.util.Date;
@Data
@TableName("mcp_config")
public class McpConfig {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
private Long spaceId;
private Long creatorId;
private String uid;
private String name;
private String serverName;
private String description;
private String category;
private String icon;
private InstallTypeEnum installType;
private DeployStatusEnum deployStatus;
private String config;
private String deployedConfig;
private Date deployed;
private Date modified;
private Date created;
}

View File

@@ -0,0 +1,25 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>app-platform-mcp-application</artifactId>
<name>app-platform-mcp-application</name>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-domain</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-sdk</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-agent-core-adapter</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,14 @@
package com.xspaceagi.mcp.application.dto;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import lombok.Data;
import java.io.Serializable;
@Data
public class EnNameDto implements Serializable {
@JsonPropertyDescription("根据用户输入生成一个英文名称如果输入本身不是中文则直接返回如果中文没有任何含义可以返回拼音。不超过32个字符不能出现空格使用小写多个单词用下划线隔开例如 query_price")
private String enName;
}

View File

@@ -0,0 +1,35 @@
package com.xspaceagi.mcp.application.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class McpExecuteResultDto implements Serializable {
@Schema(description = "执行结果状态")
private boolean success;
@Schema(description = "执行结果")
private Object result;
@Schema(description = "执行日志")
private List<String> logs;
@Schema(description = "执行错误信息")
private String error;
@Schema(description = "请求ID")
private String requestId;
@Schema(description = "请求耗时")
private Long costTime;
}

View File

@@ -0,0 +1,199 @@
package com.xspaceagi.mcp.application.service;
import com.alibaba.fastjson2.JSON;
import com.xspaceagi.mcp.adapter.domain.McpConfigDomainService;
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
import com.xspaceagi.mcp.infra.client.McpAsyncClientWrapper;
import com.xspaceagi.mcp.infra.rpc.McpDeployRpcService;
import com.xspaceagi.mcp.sdk.dto.*;
import com.xspaceagi.mcp.sdk.enums.DeployStatusEnum;
import com.xspaceagi.mcp.sdk.enums.McpDataTypeEnum;
import com.xspaceagi.system.spec.utils.MD5;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
public abstract class AbstractDeployTaskService {
@Resource
private McpDeployRpcService mcpDeployRpcService;
@Resource
private McpConfigDomainService mcpConfigDomainService;
protected void updateAndSaveMcpConfig(Long id, String serverConfig, boolean isSSE, Date modified) {
McpConfigDto mcpConfigDto = new McpConfigDto();
mcpConfigDto.setServerConfig(serverConfig);
McpAsyncClientWrapper mcpAsyncClientWrapper = null;
try {
if (isSSE) {
mcpAsyncClientWrapper = mcpDeployRpcService.getMcpAsyncClientForSSE(String.valueOf(id), MD5.MD5Encode(serverConfig), serverConfig).block();
} else {
mcpAsyncClientWrapper = mcpDeployRpcService.getMcpAsyncClient(String.valueOf(id), MD5.MD5Encode(serverConfig), serverConfig).block();
}
McpSchema.ListToolsResult toolsResult = mcpAsyncClientWrapper.getClient().listTools().timeout(Duration.ofSeconds(30)).block();
if (toolsResult.tools() != null) {
mcpConfigDto.setTools(new ArrayList<>());
for (McpSchema.Tool tool : toolsResult.tools()) {
McpToolDto mcpToolDto = new McpToolDto();
mcpToolDto.setName(tool.name());
mcpToolDto.setDescription(tool.description());
mcpToolDto.setInputArgs(convertInputSchemaToInputArgs(tool.inputSchema().properties(), tool.inputSchema().required()));
mcpToolDto.setJsonSchema(JSON.toJSONString(tool.inputSchema()));
mcpConfigDto.getTools().add(mcpToolDto);
}
}
try {
//数据量可能比较大,后续优化
McpSchema.ListResourcesResult resourcesResult = mcpAsyncClientWrapper.getClient().listResources().timeout(Duration.ofSeconds(30)).block();
if (resourcesResult != null) {
mcpConfigDto.setResources(new ArrayList<>());
for (McpSchema.Resource resource : resourcesResult.resources()) {
McpResourceDto mcpResourceDto = new McpResourceDto();
mcpResourceDto.setUri(resource.uri());
mcpResourceDto.setName(resource.name());
mcpResourceDto.setDescription(resource.description());
mcpResourceDto.setMimeType(resource.mimeType());
mcpResourceDto.setAnnotations(convertToAnnotations(resource.annotations()));
McpArgDto mcpArgDto = new McpArgDto();
mcpArgDto.setName("uri");
mcpArgDto.setDescription("uri");
mcpArgDto.setBindValue(resource.uri());
mcpArgDto.setKey(resource.uri());
mcpArgDto.setDataType(McpDataTypeEnum.String);
mcpArgDto.setRequire(true);
mcpResourceDto.setInputArgs(List.of(mcpArgDto));
mcpConfigDto.getResources().add(mcpResourceDto);
}
}
} catch (Exception e) {
log.warn("listResources error {}", e.getMessage());
// ignore 服务未提供resource功能时会报错
}
try {
McpSchema.ListPromptsResult listPromptsResult = mcpAsyncClientWrapper.getClient().listPrompts().timeout(Duration.ofSeconds(30)).block();
if (listPromptsResult != null) {
mcpConfigDto.setPrompts(new ArrayList<>());
for (McpSchema.Prompt prompt : listPromptsResult.prompts()) {
McpPromptDto mcpPromptDto = new McpPromptDto();
mcpPromptDto.setName(prompt.name());
mcpPromptDto.setDescription(prompt.description());
if (prompt.arguments() != null) {
mcpPromptDto.setInputArgs(prompt.arguments().stream().map(argument -> {
McpArgDto mcpArgDto = new McpArgDto();
mcpArgDto.setName(argument.name());
mcpArgDto.setKey(argument.name());
mcpArgDto.setDescription(argument.description());
mcpArgDto.setRequire(argument.required());
mcpArgDto.setDataType(McpDataTypeEnum.String);
return mcpArgDto;
}).collect(Collectors.toList()));
}
mcpConfigDto.getPrompts().add(mcpPromptDto);
}
}
} catch (Exception e) {
log.warn("listPrompts error {}", e.getMessage());
// ignore 服务未提供resource功能时会报错
}
} catch (Throwable e) {
log.error("McpDeployRpcServiceImpl.deploy error", e);
throw e;
} finally {
if (mcpAsyncClientWrapper != null) {
try {
mcpDeployRpcService.closeMcpClient(String.valueOf(id), MD5.MD5Encode(serverConfig), mcpAsyncClientWrapper);
} catch (Exception e) {
// ignore
log.error("McpDeployRpcServiceImpl.deploy closeMcpClient error", e);
}
}
}
McpConfig mcpConfig = new McpConfig();
mcpConfig.setId(id);
mcpConfig.setDeployStatus(DeployStatusEnum.Deployed);
mcpConfig.setDeployed(new Date());
mcpConfig.setConfig(JSON.toJSONString(mcpConfigDto));
mcpConfig.setDeployedConfig(JSON.toJSONString(mcpConfigDto));
if (modified != null) {
mcpConfig.setModified(modified);
}
mcpConfigDomainService.update(mcpConfig);
}
private McpResourceContent.Annotations convertToAnnotations(McpSchema.Annotations annotations) {
List<McpSchema.Role> audience = annotations.audience();
List<String> audience0 = null;
if (audience != null) {
audience0 = audience.stream().map(McpSchema.Role::name).collect(Collectors.toList());
}
return new McpResourceContent.Annotations(audience0, annotations.priority());
}
private List<McpArgDto> convertInputSchemaToInputArgs(Map<String, Object> properties, List<String> requiredArgs) {
List<McpArgDto> mcpArgDtos = new ArrayList<>();
if (properties == null || properties.size() == 0) {
return mcpArgDtos;
}
List<String> required = new ArrayList<>();
if (requiredArgs != null) {
required.addAll(requiredArgs);
}
return properties.entrySet().stream().map(entry -> {
McpArgDto mcpArgDto = new McpArgDto();
String name = entry.getKey();
mcpArgDto.setName(name);
mcpArgDto.setKey(name);
if (required.contains(name)) {
mcpArgDto.setRequire(true);
}
if (entry.getValue() instanceof Map<?, ?>) {
Map<String, Object> value = (Map<String, Object>) entry.getValue();
mcpArgDto.setDescription(value.get("description") != null ? value.get("description").toString() : "");
mcpArgDto.setBindValue(value.get("default") != null ? value.get("default").toString() : null);
if ("object".equals(value.get("type"))) {
mcpArgDto.setDataType(McpDataTypeEnum.Object);
mcpArgDto.setSubArgs(convertInputSchemaToInputArgs((Map<String, Object>) value.get("properties"), (List<String>) value.get("required")));
} else if ("array".equals(value.get("type"))) {
Map<String, Object> itemSchema = (Map<String, Object>) value.get("items");
if (itemSchema != null) {
if ("object".equals(itemSchema.get("type"))) {
mcpArgDto.setDataType(McpDataTypeEnum.Array_Object);
mcpArgDto.setSubArgs(convertInputSchemaToInputArgs((Map<String, Object>) itemSchema.get("properties"), (List<String>) itemSchema.get("required")));
} else if ("number".equals(itemSchema.get("type"))) {
mcpArgDto.setDataType(McpDataTypeEnum.Array_Number);
} else if ("integer".equals(itemSchema.get("type"))) {
mcpArgDto.setDataType(McpDataTypeEnum.Array_Integer);
} else if ("boolean".equals(itemSchema.get("type"))) {
mcpArgDto.setDataType(McpDataTypeEnum.Array_Boolean);
} else {
mcpArgDto.setDataType(McpDataTypeEnum.Array_String);
}
}
} else if ("number".equals(value.get("type"))) {
mcpArgDto.setDataType(McpDataTypeEnum.Number);
} else if ("integer".equals(value.get("type"))) {
mcpArgDto.setDataType(McpDataTypeEnum.Integer);
} else if ("boolean".equals(value.get("type"))) {
mcpArgDto.setDataType(McpDataTypeEnum.Boolean);
} else {
mcpArgDto.setDataType(McpDataTypeEnum.String);
}
if (value.get("enum") != null) {
List<?> enums = (List<?>) value.get("enum");
mcpArgDto.setEnums(enums.stream().map(Object::toString).collect(Collectors.toList()));
}
}
return mcpArgDto;
}).toList();
}
}

View File

@@ -0,0 +1,718 @@
package com.xspaceagi.mcp.application.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.xspaceagi.agent.core.adapter.application.ModelApplicationService;
import com.xspaceagi.agent.core.sdk.IAgentRpcService;
import com.xspaceagi.agent.core.sdk.dto.*;
import com.xspaceagi.agent.core.spec.enums.DataTypeEnum;
import com.xspaceagi.compose.sdk.request.DorisTableDefineRequest;
import com.xspaceagi.compose.sdk.service.IComposeDbTableRpcService;
import com.xspaceagi.compose.sdk.vo.define.TableDefineVo;
import com.xspaceagi.compose.sdk.vo.define.TableFieldDefineVo;
import com.xspaceagi.knowledge.sdk.response.KnowledgeConfigVo;
import com.xspaceagi.knowledge.sdk.sevice.IKnowledgeConfigRpcService;
import com.xspaceagi.mcp.adapter.application.McpConfigApplicationService;
import com.xspaceagi.mcp.adapter.application.McpDeployTaskService;
import com.xspaceagi.mcp.adapter.domain.McpConfigDomainService;
import com.xspaceagi.mcp.adapter.dto.McpPageQueryDto;
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
import com.xspaceagi.mcp.application.dto.EnNameDto;
import com.xspaceagi.mcp.sdk.dto.*;
import com.xspaceagi.mcp.sdk.enums.DeployStatusEnum;
import com.xspaceagi.mcp.sdk.enums.InstallTypeEnum;
import com.xspaceagi.mcp.sdk.enums.McpComponentTypeEnum;
import com.xspaceagi.mcp.sdk.enums.McpDataTypeEnum;
import com.xspaceagi.system.application.dto.TenantConfigDto;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.application.service.UserApplicationService;
import com.xspaceagi.system.application.util.DefaultIconUrlUtil;
import com.xspaceagi.system.sdk.service.UserAccessKeyApiService;
import com.xspaceagi.system.sdk.service.dto.UserAccessKeyDto;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.enums.YnEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
public class McpConfigApplicationServiceImpl implements McpConfigApplicationService {
@Resource
private McpConfigDomainService mcpConfigDomainService;
@Resource
private UserApplicationService userApplicationService;
@Resource
private IAgentRpcService iAgentRpcService;
@Resource
private IComposeDbTableRpcService iComposeDbTableRpcService;
@Resource
private IKnowledgeConfigRpcService iKnowledgeConfigRpcService;
@Resource
private UserAccessKeyApiService userAccessKeyApiService;
@Resource
private McpDeployTaskService mcpDeployTaskService;
@Resource
private ModelApplicationService modelApplicationService;
@Override
public void addMcp(McpDto mcpDto) {
mcpDto.setId(null);
McpConfig mcpConfig = new McpConfig();
BeanUtils.copyProperties(mcpDto, mcpConfig);
completeComponentMcpToolNames(mcpDto);
mcpConfig.setConfig(JSON.toJSONString(mcpDto.getMcpConfig()));
if (mcpDto.getDeployedConfig() != null) {
mcpConfig.setDeployedConfig(JSON.toJSONString(mcpDto.getDeployedConfig()));
}
mcpConfigDomainService.add(mcpConfig);
mcpDto.setId(mcpConfig.getId());
}
private void completeComponentMcpToolNames(McpDto mcpDto) {
if (mcpDto.getInstallType() != InstallTypeEnum.COMPONENT) {
return;
}
Map<String, McpComponentDto> componentMap = new HashMap<>();
List<String> toolNames = new ArrayList<>();
if (mcpDto.getId() != null) {
McpConfig mcpConfig = mcpConfigDomainService.getById(mcpDto.getId());
McpConfigDto mcpConfigDto = JSON.parseObject(mcpConfig.getConfig(), McpConfigDto.class);
if (mcpConfigDto.getComponents() != null) {
//type + targetId作为key
componentMap = mcpConfigDto.getComponents().stream().collect(Collectors.toMap(componentDto -> componentDto.getType().name() + "_" + componentDto.getTargetId(), McpComponentDto -> McpComponentDto, (a, b) -> a));
toolNames = mcpConfigDto.getComponents().stream().map(McpComponentDto::getToolName).collect(Collectors.toList());
}
}
if (mcpDto.getMcpConfig() != null && mcpDto.getMcpConfig().getComponents() != null) {
for (McpComponentDto componentDto : mcpDto.getMcpConfig().getComponents()) {
String key = componentDto.getType().name() + "_" + componentDto.getTargetId();
McpComponentDto component = componentMap.get(key);
//存在则使用
if (component != null && StringUtils.isNotBlank(component.getToolName())) {
componentDto.setToolName(component.getToolName());
} else {
EnNameDto enNameDto = null;
try {
String prompt = componentDto.getName();
if (StringUtils.isNotBlank(componentDto.getDescription())) {
prompt += "(" + componentDto.getDescription() + ")";
}
enNameDto = modelApplicationService.call(prompt, new ParameterizedTypeReference<EnNameDto>() {
});
} catch (Exception e) {
log.warn("call model name error", e);
//忽略
}
if (enNameDto != null && StringUtils.isNotBlank(enNameDto.getEnName())) {
String toolName = enNameDto.getEnName().replace(" ", "");
if (toolNames.contains(toolName)) {
toolName = toolName + "_" + componentDto.getTargetId();
toolNames.add(toolName);
}
componentDto.setToolName(toolName);
}
}
}
}
}
@Override
public void updateMcp(McpDto mcpDto) {
Assert.notNull(mcpDto, "mcpDto must be non-null");
Assert.notNull(mcpDto.getId(), "id must be non-null");
McpDto mcp = getMcp(mcpDto.getId());
if (mcp == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.mcpNotFound);
}
mcpDto.setInstallType(mcp.getInstallType());
completeComponentMcpToolNames(mcpDto);
McpConfig mcpConfig = new McpConfig();
BeanUtils.copyProperties(mcpDto, mcpConfig);
if (mcpDto.getMcpConfig() != null) {
mcpConfig.setConfig(JSON.toJSONString(mcpDto.getMcpConfig()));
}
if (mcpDto.getDeployStatus() == DeployStatusEnum.Stopped) {
mcpConfig.setDeployedConfig(null);
}
if (mcpDto.getIcon() != null && mcpDto.getIcon().contains("api/logo/")) {
mcpConfig.setIcon(null);
}
mcpConfigDomainService.update(mcpConfig);
}
@Override
public void deleteMcp(Long id) {
mcpConfigDomainService.delete(id);
}
@Override
public McpDto getMcp(Long id) {
return getMcp0(id, false);
}
private McpDto getMcp0(Long id, boolean deployed) {
McpConfig mcpConfig = mcpConfigDomainService.getById(id);
if (mcpConfig == null) {
return null;
}
if (deployed && (mcpConfig.getDeployStatus() == DeployStatusEnum.Initialization || mcpConfig.getDeployStatus() == DeployStatusEnum.Stopped || mcpConfig.getDeployedConfig() == null)) {
return null;
}
McpDto mcpDto = new McpDto();
BeanUtils.copyProperties(mcpConfig, mcpDto);
mcpDto.setMcpConfig(convertToMcpConfig(mcpConfig.getConfig()));
mcpDto.setDeployedConfig(convertToMcpConfig(mcpConfig.getDeployedConfig()));
if (mcpDto.getInstallType() == InstallTypeEnum.COMPONENT) {
completeComponent(mcpDto.getDeployedConfig(), mcpConfig.getSpaceId());
if (mcpDto.getMcpConfig() != null && mcpDto.getDeployStatus() == DeployStatusEnum.Deployed) {
mcpDto.getMcpConfig().setTools(mcpDto.getDeployedConfig().getTools());
}
}
UserDto userDto = userApplicationService.queryById(mcpConfig.getCreatorId());
CreatorDto creatorDto = convertUserToCreator(userDto);
mcpDto.setCreator(creatorDto);
mcpDto.setIcon(DefaultIconUrlUtil.setDefaultIconUrl(mcpDto.getIcon(), mcpDto.getName(), "mcp"));
return mcpDto;
}
private McpConfigDto convertToMcpConfig(String config) {
if (!JSON.isValid(config)) {
return null;
}
McpConfigDto mcpConfigDto = JSON.parseObject(config, McpConfigDto.class);
if (mcpConfigDto == null) {
return null;
}
if (CollectionUtils.isNotEmpty(mcpConfigDto.getTools())) {
mcpConfigDto.getTools().forEach(mcpToolDto -> {
completeMcpArgDtos(mcpToolDto.getInputArgs());
completeMcpArgDtos(mcpToolDto.getOutputArgs());
});
}
return mcpConfigDto;
}
private void completeMcpArgDtos(List<McpArgDto> inputArgs) {
if (CollectionUtils.isNotEmpty(inputArgs)) {
inputArgs.forEach(mcpArgDto -> {
if (mcpArgDto.getDataType() == null) {
mcpArgDto.setDataType(McpDataTypeEnum.String);
}
completeMcpArgDtos(mcpArgDto.getSubArgs());
});
}
}
@Override
public McpDto getDeployedMcp(Long id) {
return getMcp0(id, true);
}
private void completeComponent(McpConfigDto mcpConfig, Long spaceId) {
if (mcpConfig != null && CollectionUtils.isNotEmpty(mcpConfig.getComponents())) {
mcpConfig.setResources(new ArrayList<>());
mcpConfig.setTools(new ArrayList<>());
mcpConfig.setPrompts(new ArrayList<>());
List<McpComponentDto> components = mcpConfig.getComponents();
Iterator<McpComponentDto> ite = components.iterator();
while (ite.hasNext()) {
McpComponentDto component = ite.next();
if (component.getType() == McpComponentTypeEnum.Agent) {
ReqResult<AgentInfoDto> agentInfoDtoReqResult = iAgentRpcService.queryPublishedAgentInfo(component.getTargetId());
if (!agentInfoDtoReqResult.isSuccess()) {
ite.remove();
continue;
}
AgentInfoDto agentInfoDto = agentInfoDtoReqResult.getData();
component.setName(agentInfoDto.getName());
component.setDescription(agentInfoDto.getDescription());
component.setIcon(DefaultIconUrlUtil.setDefaultIconUrl(agentInfoDto.getIcon(), agentInfoDto.getName(), McpComponentTypeEnum.Agent.name()));
McpToolDto mcpToolDto = new McpToolDto();
mcpToolDto.setName("agent_execute_" + agentInfoDto.getId());
if (StringUtils.isNotBlank(component.getToolName())) {
mcpToolDto.setName(component.getToolName());
}
mcpToolDto.setDescription(agentInfoDto.getDescription());
List<McpArgDto> inputArgs = new ArrayList<>();
mcpToolDto.setInputArgs(inputArgs);
mcpToolDto.getInputArgs().add(McpArgDto.builder().key("message").name("message").description("用户提示词消息内容如果有文档、图片等附件内容可以将相关的URL地址一并放置在用户提示词消息中")
.require(true)
.dataType(McpDataTypeEnum.String)
.build());
if (CollectionUtils.isNotEmpty(agentInfoDto.getVariables())) {
McpArgDto mcpArgDto = McpArgDto.builder().key("variables").name("variables").require(false)
.dataType(McpDataTypeEnum.Object).description("变量参数格式为JSON对象")
.subArgs(convertToMcpArgDtos(agentInfoDto.getVariables())).build();
mcpToolDto.getInputArgs().add(mcpArgDto);
}
mcpToolDto.setJsonSchema(JSON.toJSONString(buildSchema(mcpToolDto.getInputArgs())));
mcpConfig.getTools().add(mcpToolDto);
component.setToolName(mcpToolDto.getName());
}
if (component.getType() == McpComponentTypeEnum.Table) {
DorisTableDefineRequest dorisTableDefineRequest = new DorisTableDefineRequest();
dorisTableDefineRequest.setTableId(component.getTargetId());
TableDefineVo tableDefineVo = iComposeDbTableRpcService.queryTableDefinition(dorisTableDefineRequest);
if (tableDefineVo == null) {
ite.remove();
continue;
}
component.setName(tableDefineVo.getTableName());
component.setDescription(tableDefineVo.getTableDescription());
component.setIcon(DefaultIconUrlUtil.setDefaultIconUrl(tableDefineVo.getIcon(), tableDefineVo.getTableName(), McpComponentTypeEnum.Table.name()));
McpToolDto mcpToolDto = new McpToolDto();
mcpToolDto.setName("sql_execute_" + tableDefineVo.getId());
if (StringUtils.isNotBlank(component.getToolName())) {
mcpToolDto.setName(component.getToolName());
}
mcpToolDto.setDescription(tableDefineVo.getTableDescription());
mcpToolDto.setInputArgs(Arrays.asList(
McpArgDto.builder().key("sql").name("sql").description("SQL语句可以直接执行且符合业务诉求的SQL语句符合MySQL语法规范。表结构" + convertArgsToSimpleTableStructure(tableDefineVo.getFieldList()))
.require(true)
.dataType(McpDataTypeEnum.String)
.build())
);
mcpToolDto.setJsonSchema(JSON.toJSONString(buildSchema(mcpToolDto.getInputArgs())));
mcpConfig.getTools().add(mcpToolDto);
component.setToolName(mcpToolDto.getName());
}
if (component.getType() == McpComponentTypeEnum.Knowledge) {
KnowledgeConfigVo knowledgeConfigVo = iKnowledgeConfigRpcService.queryKnowledgeConfigById(component.getTargetId());
if (knowledgeConfigVo == null) {
ite.remove();
continue;
}
component.setName(knowledgeConfigVo.getName());
component.setDescription(knowledgeConfigVo.getDescription());
component.setIcon(DefaultIconUrlUtil.setDefaultIconUrl(knowledgeConfigVo.getIcon(), knowledgeConfigVo.getName(), McpComponentTypeEnum.Knowledge.name()));
McpToolDto mcpToolDto = new McpToolDto();
mcpToolDto.setName("knowledge_query_" + knowledgeConfigVo.getId());
if (StringUtils.isNotBlank(component.getToolName())) {
mcpToolDto.setName(component.getToolName());
}
mcpToolDto.setDescription(knowledgeConfigVo.getDescription());
mcpToolDto.setInputArgs(List.of(
McpArgDto.builder().key("query").name("query").description("知识库搜索关键词")
.require(true)
.dataType(McpDataTypeEnum.String)
.build(),
McpArgDto.builder().key("topK").name("topK").description("返回topK条数默认5")
.require(false)
.dataType(McpDataTypeEnum.Integer)
.build(),
McpArgDto.builder().key("rawText").name("rawText").description("是否返回原始段落")
.require(false)
.dataType(McpDataTypeEnum.Boolean)
.build()
)
);
mcpToolDto.setJsonSchema(JSON.toJSONString(buildSchema(mcpToolDto.getInputArgs())));
mcpConfig.getTools().add(mcpToolDto);
component.setToolName(mcpToolDto.getName());
}
if (component.getType() == McpComponentTypeEnum.Plugin) {
ReqResult<PluginInfoDto> publishedPluginInfo = iAgentRpcService.getPublishedPluginInfo(component.getTargetId(), spaceId);
if (!publishedPluginInfo.isSuccess() || publishedPluginInfo.getData() == null) {
ite.remove();
continue;
}
PluginInfoDto pluginInfoDto = publishedPluginInfo.getData();
component.setName(pluginInfoDto.getName());
component.setIcon(DefaultIconUrlUtil.setDefaultIconUrl(pluginInfoDto.getIcon(), pluginInfoDto.getName(), McpComponentTypeEnum.Plugin.name()));
component.setDescription(pluginInfoDto.getDescription());
McpToolDto mcpToolDto = new McpToolDto();
mcpToolDto.setName("tool_plugin_" + pluginInfoDto.getId());
if (StringUtils.isNotBlank(component.getToolName())) {
mcpToolDto.setName(component.getToolName());
}
mcpToolDto.setDescription(pluginInfoDto.getDescription());
if (pluginInfoDto.getInputArgs() != null) {
List<ArgDto> bindArgs = null;
if (StringUtils.isNotBlank(component.getTargetBindConfig())) {
bindArgs = iAgentRpcService.parseAgentPluginBindArgs(component.getTargetBindConfig());
}
//移除未启用的参数
removeDisabledArgs(pluginInfoDto.getInputArgs(), bindArgs);
List<McpArgDto> argDtos = convertToMcpArgDtos(pluginInfoDto.getInputArgs());
mcpToolDto.setInputArgs(argDtos);
}
mcpToolDto.setJsonSchema(JSON.toJSONString(buildSchema(mcpToolDto.getInputArgs())));
mcpConfig.getTools().add(mcpToolDto);
component.setToolName(mcpToolDto.getName());
component.setTargetConfig(pluginInfoDto.getConfig());
}
if (component.getType() == McpComponentTypeEnum.Workflow) {
ReqResult<WorkflowInfoDto> publishedWorkflowInfo = iAgentRpcService.getPublishedWorkflowInfo(component.getTargetId(), spaceId);
if (!publishedWorkflowInfo.isSuccess() || publishedWorkflowInfo.getData() == null) {
ite.remove();
continue;
}
WorkflowInfoDto workflowInfoDto = publishedWorkflowInfo.getData();
component.setName(workflowInfoDto.getName());
component.setIcon(DefaultIconUrlUtil.setDefaultIconUrl(workflowInfoDto.getIcon(), workflowInfoDto.getName(), McpComponentTypeEnum.Workflow.name()));
component.setDescription(workflowInfoDto.getDescription());
McpToolDto mcpToolDto = new McpToolDto();
mcpToolDto.setName("tool_workflow_" + workflowInfoDto.getId());
if (StringUtils.isNotBlank(component.getToolName())) {
mcpToolDto.setName(component.getToolName());
}
if (workflowInfoDto.getInputArgs() != null) {
List<ArgDto> bindArgs = null;
if (StringUtils.isNotBlank(component.getTargetBindConfig())) {
bindArgs = iAgentRpcService.parseWorkflowPluginBindArgs(component.getTargetBindConfig());
}
//移除未启用的参数
removeDisabledArgs(workflowInfoDto.getInputArgs(), bindArgs);
List<McpArgDto> argDtos = convertToMcpArgDtos(workflowInfoDto.getInputArgs());
mcpToolDto.setInputArgs(argDtos);
}
mcpToolDto.setDescription(workflowInfoDto.getDescription());
mcpToolDto.setJsonSchema(JSON.toJSONString(buildSchema(mcpToolDto.getInputArgs())));
mcpConfig.getTools().add(mcpToolDto);
component.setToolName(mcpToolDto.getName());
component.setTargetConfig(workflowInfoDto.getConfig());
}
}
}
}
private void removeDisabledArgs(List<ArgDto> inputArgs, List<ArgDto> bindArgs) {
Map<String, ArgDto> bindArgMap = null;
if (bindArgs != null) {
bindArgMap = bindArgs.stream().collect(Collectors.toMap(ArgDto::getName, a -> a, (a, b) -> a));
}
Map<String, ArgDto> finalBindArgMap = bindArgMap;
inputArgs.removeIf(arg -> {
if (arg.getEnable() != null && !arg.getEnable()) {
return true;
}
if (finalBindArgMap != null) {
ArgDto bindArg = finalBindArgMap.get(arg.getName());
return bindArg != null && bindArg.getEnable() != null && !bindArg.getEnable();
}
return false;
});
inputArgs.forEach(arg -> {
if (arg.getSubArgs() != null && !arg.getSubArgs().isEmpty()) {
ArgDto bindArg = finalBindArgMap == null ? null : finalBindArgMap.get(arg.getName());
removeDisabledArgs(arg.getSubArgs(), bindArg == null ? null : bindArg.getSubArgs());
}
});
}
private List<McpArgDto> convertToMcpArgDtos(List<ArgDto> inputArgs) {
if (inputArgs == null) {
return new ArrayList<>();
}
return inputArgs.stream().map(arg -> {
McpArgDto mcpArgDto = new McpArgDto();
mcpArgDto.setName(arg.getName());
mcpArgDto.setDescription(arg.getDescription());
try {
if (arg.getDataType().name().startsWith("File")) {
mcpArgDto.setDataType(McpDataTypeEnum.String);
} else if (arg.getDataType().name().startsWith("Array_File")) {
mcpArgDto.setDataType(McpDataTypeEnum.Array_String);
} else {
mcpArgDto.setDataType(McpDataTypeEnum.valueOf(arg.getDataType().name()));
}
} catch (Exception e) {
mcpArgDto.setDataType(McpDataTypeEnum.String);
}
mcpArgDto.setRequire(arg.isRequire());
mcpArgDto.setKey(arg.getKey());
mcpArgDto.setSubArgs(convertToMcpArgDtos(arg.getSubArgs()));
return mcpArgDto;
}).collect(Collectors.toList());
}
private CreatorDto convertUserToCreator(UserDto userDto) {
if (userDto == null) {
return null;
}
return CreatorDto.builder()
.userId(userDto.getId())
.userName(userDto.getUserName())
.nickName(userDto.getNickName())
.avatar(userDto.getAvatar())
.build();
}
@Override
public List<McpDto> queryMcpListBySpaceId(Long spaceId) {
List<McpDto> mcpDtos = mcpConfigDomainService.queryListBySpaceId(spaceId).stream().map(mcpConfig -> {
boolean isPlatformMcp = mcpConfig.getConfig() != null && mcpConfig.getConfig().contains("TENANT_SECRET");
mcpConfig.setDeployedConfig(null);
mcpConfig.setConfig(null);
McpDto mcpDto = new McpDto();
BeanUtils.copyProperties(mcpConfig, mcpDto);
mcpDto.setPlatformMcp(isPlatformMcp);
mcpDto.setIcon(DefaultIconUrlUtil.setDefaultIconUrl(mcpDto.getIcon(), mcpDto.getName(), "mcp"));
return mcpDto;
}).collect(Collectors.toList());
completeCreator(mcpDtos);
return mcpDtos;
}
private void completeCreator(List<McpDto> mcpDtos) {
List<UserDto> userDtos = userApplicationService.queryUserListByIds(mcpDtos.stream().map(McpDto::getCreatorId).collect(Collectors.toList()));
Map<Long, UserDto> userMap = userDtos.stream().collect(Collectors.toMap(UserDto::getId, userDto -> userDto));
TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig();
mcpDtos.forEach(mcpDto -> {
UserDto userDto = userMap.get(mcpDto.getCreatorId());
if (userDto == null) {
userDto = new UserDto();
userDto.setId(-1L);
userDto.setUserName("");
}
if (StringUtils.isBlank(userDto.getNickName())) {
userDto.setNickName(userDto.getUserName());
}
mcpDto.setCreator(CreatorDto.builder()
.userId(userDto.getId())
.userName(userDto.getUserName())
.nickName(StringUtils.isNotBlank(tenantConfigDto.getOfficialUserName()) && mcpDto.getSpaceId() != null && mcpDto.getSpaceId() == -1L ? tenantConfigDto.getOfficialUserName() : userDto == null ? "" : userDto.getNickName())
.avatar(userDto.getAvatar())
.build());
});
}
@Override
public IPage<McpDto> queryDeployedMcpList(McpPageQueryDto mcpPageQueryDto) {
IPage<McpDto> mcpPage = mcpConfigDomainService.queryDeployedMcpList(mcpPageQueryDto).convert(mcpConfig -> convertToMcpDto(mcpConfig));
List<McpDto> mcpDtos = mcpPage.getRecords();
completeCreator(mcpDtos);
return mcpPage;
}
@Override
public IPage<McpDto> queryDeployedMcpListForManage(McpPageQueryDto mcpPageQueryDto) {
IPage<McpDto> mcpPage = mcpConfigDomainService.queryDeployedMcpListForManage(mcpPageQueryDto).convert(mcpConfig -> convertToMcpDto(mcpConfig));
List<McpDto> mcpDtos = mcpPage.getRecords();
completeCreator(mcpDtos);
return mcpPage;
}
private McpDto convertToMcpDto(McpConfig mcpConfig) {
McpDto mcpDto = new McpDto();
BeanUtils.copyProperties(mcpConfig, mcpDto);
mcpDto.setIcon(DefaultIconUrlUtil.setDefaultIconUrl(mcpDto.getIcon(), mcpDto.getName(), "mcp"));
mcpDto.setDeployedConfig(JSON.parseObject(mcpConfig.getDeployedConfig(), McpConfigDto.class));
if (mcpDto.getDeployedConfig() != null) {
mcpDto.getDeployedConfig().setServerConfig(null);
mcpDto.getDeployedConfig().setResources(null);
mcpDto.getDeployedConfig().setPrompts(null);
if (mcpDto.getDeployedConfig().getTools() != null) {
mcpDto.getDeployedConfig().getTools().forEach(tool -> {
tool.setInputArgs(null);
tool.setJsonSchema(null);
});
}
}
if (mcpDto.getInstallType() == InstallTypeEnum.COMPONENT) {
completeComponent(mcpDto.getDeployedConfig(), mcpConfig.getSpaceId());
mcpDto.getDeployedConfig().setComponents(null);
}
return mcpDto;
}
@Override
public String getExportMcpServerConfig(Long userId, Long mcpId, UserAccessKeyDto.UserAccessKeyConfig userAccessKeyConfig) {
List<UserAccessKeyDto> userAccessKeyDtos = userAccessKeyApiService.queryAccessKeyList(userId, UserAccessKeyDto.AKTargetType.Mcp, mcpId.toString());
UserAccessKeyDto userAccessKeyDto;
if (CollectionUtils.isEmpty(userAccessKeyDtos)) {
userAccessKeyDto = userAccessKeyApiService.newAccessKey(userId, UserAccessKeyDto.AKTargetType.Mcp, mcpId.toString());
} else {
userAccessKeyDto = userAccessKeyDtos.get(0);
}
if (userAccessKeyConfig != null) {
userAccessKeyApiService.updateUserAccessKeyConfig(userAccessKeyDto.getId(), userAccessKeyConfig);
}
McpDto mcpDto = getMcp(mcpId);
TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig();
String siteUrl = tenantConfigDto.getSiteUrl().trim().endsWith("/") ? tenantConfigDto.getSiteUrl() : tenantConfigDto.getSiteUrl() + "/";
String sseUrl = siteUrl + "api/mcp/sse?ak=" + userAccessKeyDto.getAccessKey();
JSONObject jsonObject = new JSONObject();
JSONObject mcpServers = new JSONObject();
JSONObject mcpServer = new JSONObject();
jsonObject.put("mcpServers", mcpServers);
if (StringUtils.isBlank(mcpDto.getServerName())) {
mcpServers.put("mcp-server-" + mcpId, mcpServer);
} else {
mcpServers.put(mcpDto.getServerName(), mcpServer);
}
mcpServer.put("type", "sse");
mcpServer.put("url", sseUrl);
return jsonObject.toJSONString();
}
@Override
public String refreshExportMcpServerConfig(Long userId, Long mcpId) {
List<UserAccessKeyDto> userAccessKeyDtos = userAccessKeyApiService.queryAccessKeyList(userId, UserAccessKeyDto.AKTargetType.Mcp, mcpId.toString());
if (CollectionUtils.isNotEmpty(userAccessKeyDtos)) {
userAccessKeyApiService.refreshAccessKey(userAccessKeyDtos.get(0).getId());
}
return getExportMcpServerConfig(userId, mcpId, null);
}
public static String convertArgsToSimpleTableStructure(List<TableFieldDefineVo> fieldDefineVos) {
StringBuilder sb = new StringBuilder();
if (fieldDefineVos == null) {
return sb.toString();
}
sb.append("CREATE TABLE IF NOT EXISTS `custom_table` (");
fieldDefineVos.forEach(arg -> {
sb.append(arg.getFieldName()).append(" ").append(getMysqlType(getDataType(arg.getFieldType()))).append(" ");
Boolean require = YnEnum.Y.getKey().equals(arg.getNullableFlag()) ? false : true;
if (require) {
sb.append("NOT NULL ");
}
sb.append("COMMENT '").append(arg.getFieldDescription().replace("'", "\\'")).append("'");
sb.append(",");
});
return sb.substring(0, sb.length() - 1) + ")";
}
private static DataTypeEnum getDataType(Integer fieldType) {
//1:String;2:Integer;3:Number;4:Boolean;5:Date
switch (fieldType) {
case 2:
return DataTypeEnum.Integer;
case 3:
return DataTypeEnum.Number;
case 4:
return DataTypeEnum.Boolean;
default:
return DataTypeEnum.String;
}
}
private static String getMysqlType(DataTypeEnum dataType) {
switch (dataType) {
case String:
return "varchar(255)";
case Integer:
return "int(10)";
case Number:
return "bigDecimal";
case Boolean:
return "tinyint(4)";
}
return "datetime";
}
private static Map<String, Object> buildSchema(List<McpArgDto> inputArgs) {
if (inputArgs == null) {
return new HashMap<>();
}
List<String> required = new ArrayList<>();
Map<String, Object> properties = new HashMap<>();
Map<String, Object> params = new HashMap<>();
params.putAll(Map.of("type", "object", "properties", properties, "required", required));
if (inputArgs != null && inputArgs.size() > 0) {
for (McpArgDto inputArg : inputArgs) {
if (inputArg.isRequire()) {
required.add(inputArg.getName());
}
McpDataTypeEnum dataType = inputArg.getDataType();
if (dataType == McpDataTypeEnum.Object) {
properties.put(inputArg.getName(), buildSchema(inputArg.getSubArgs()));
continue;
}
if (dataType == McpDataTypeEnum.Array_Object) {
properties.put(inputArg.getName(), Map.of("type", "array",
"items", buildSchema(inputArg.getSubArgs())));
continue;
}
if (dataType.name().startsWith("Array_String") || dataType.name().startsWith("Array_Number")
|| dataType.name().startsWith("Array_Boolean") || dataType.name().startsWith("Array_Integer")) {
properties.put(inputArg.getName(), Map.of("type", "array",
"items", Map.of("type", inputArg.getDataType().name().split("_")[1].toLowerCase())));
continue;
}
if (inputArg.getDataType().name().startsWith("Array")) {
properties.put(inputArg.getName(), Map.of("type", "array", "items", Map.of("type", "string")));
continue;
}
String dataTypeStr = McpDataTypeEnum.String.name().toLowerCase();
if (inputArg.getDataType() == McpDataTypeEnum.Number) {
dataTypeStr = DataTypeEnum.Number.name().toLowerCase();
} else if (inputArg.getDataType() == McpDataTypeEnum.Boolean) {
dataTypeStr = DataTypeEnum.Boolean.name().toLowerCase();
} else if (inputArg.getDataType() == McpDataTypeEnum.Integer) {
dataTypeStr = DataTypeEnum.Integer.name().toLowerCase();
}
properties.put(inputArg.getName(), Map.of("type", dataTypeStr, "description", inputArg.getDescription() == null ? inputArg.getName() : inputArg.getDescription()));
}
}
return params;
}
@Override
public Long deployOfficialMcp(McpDto mcpDto) {
Assert.notNull(mcpDto.getUid(), "uid must be non-null");
mcpDto.setSpaceId(-1L);
McpConfig mcpConfig = mcpConfigDomainService.getBySpaceIdAndUid(-1L, mcpDto.getUid());
Long mcpId;
if (mcpConfig != null) {
mcpDto.setId(mcpConfig.getId());
updateMcp(mcpDto);
mcpDto.setId(mcpConfig.getId());
mcpId = mcpConfig.getId();
} else {
addMcp(mcpDto);
mcpId = mcpDto.getId();
}
mcpDeployTaskService.addDeployTask(mcpDto);
return mcpId;
}
@Override
public void stopOfficialMcp(Long id) {
McpDto update = new McpDto();
update.setId(id);
update.setDeployStatus(DeployStatusEnum.Stopped);
updateMcp(update);
}
@Override
public Long deployProxyMcp(McpDto mcpDto) {
Long id = mcpDto.getId();
if (mcpDto.getId() == null) {
addMcp(mcpDto);
id = mcpDto.getId();
} else {
updateMcp(mcpDto);
}
mcpDeployTaskService.addDeployTask(mcpDto, false);
return id;
}
@Override
public Long countTotalMcps() {
return mcpConfigDomainService.countTotalMcps();
}
}

View File

@@ -0,0 +1,192 @@
package com.xspaceagi.mcp.application.service;
import com.alibaba.fastjson2.JSON;
import com.xspaceagi.mcp.adapter.application.McpDeployTaskService;
import com.xspaceagi.mcp.adapter.domain.McpConfigDomainService;
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
import com.xspaceagi.mcp.infra.rpc.McpDeployRpcService;
import com.xspaceagi.mcp.infra.rpc.dto.McpDeployStatusResponse;
import com.xspaceagi.mcp.infra.rpc.enums.McpDeployStatusEnum;
import com.xspaceagi.mcp.infra.rpc.enums.McpPersistentTypeEnum;
import com.xspaceagi.mcp.sdk.dto.McpDto;
import com.xspaceagi.mcp.sdk.enums.DeployStatusEnum;
import com.xspaceagi.mcp.sdk.enums.InstallTypeEnum;
import com.xspaceagi.system.application.dto.SendNotifyMessageDto;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.application.service.NotifyMessageApplicationService;
import com.xspaceagi.system.application.service.UserApplicationService;
import com.xspaceagi.system.infra.dao.entity.NotifyMessage;
import com.xspaceagi.system.sdk.service.ScheduleTaskApiService;
import com.xspaceagi.system.sdk.service.TaskExecuteService;
import com.xspaceagi.system.sdk.service.dto.ScheduleTaskDto;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.utils.I18nUtil;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Slf4j
@Service("mcpDeployTaskService")
public class McpDeployTaskServiceImpl extends AbstractDeployTaskService implements McpDeployTaskService, TaskExecuteService {
private static final Long MAX_EXECUTE_TIMES = 60L;
@Resource
private ScheduleTaskApiService scheduleTaskApiService;
@Resource
private McpDeployRpcService mcpDeployRpcService;
@Resource
private McpConfigDomainService mcpConfigDomainService;
@Resource
private NotifyMessageApplicationService notifyMessageApplicationService;
@Resource
private UserApplicationService userApplicationService;
@Override
public void addDeployTask(McpDto mcpDto) {
addDeployTask(mcpDto, true);
}
@Override
public void addDeployTask(McpDto mcpDto, boolean notify) {
if (mcpDto.getInstallType() == InstallTypeEnum.COMPONENT) {
McpConfig mcpConfig = new McpConfig();
mcpConfig.setId(mcpDto.getId());
mcpConfig.setConfig(JSON.toJSONString(mcpDto.getMcpConfig()));
mcpConfig.setDeployedConfig(JSON.toJSONString(mcpDto.getMcpConfig()));
mcpConfig.setDeployStatus(DeployStatusEnum.Deployed);
mcpConfig.setDeployed(new Date());
mcpConfigDomainService.update(mcpConfig);
if (notify) {
notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder()
.scope(NotifyMessage.MessageScope.System)
.content(I18nUtil.systemMessage("Backend.Mcp.Deploy.Success", mcpDto.getName()))
.userIds(Arrays.asList(mcpDto.getCreatorId()))
.build());
}
return;
}
String taskId = "mcp:" + mcpDto.getId() + ":" + System.currentTimeMillis();
long maxExecuteTimes = MAX_EXECUTE_TIMES;
if (mcpDto.getInstallType() == InstallTypeEnum.SSE || mcpDto.getInstallType() == InstallTypeEnum.STREAMABLE_HTTP) {
maxExecuteTimes = 5;
}
scheduleTaskApiService.start(ScheduleTaskDto.builder()
.taskId(taskId)
.beanId("mcpDeployTaskService")
.maxExecTimes(maxExecuteTimes)
.cron(ScheduleTaskDto.Cron.EVERY_10_SECOND.getCron())
.params(Map.of("id", mcpDto.getId(),
"tenantId", RequestContext.get().getTenantId(),
"userId", mcpDto.getCreatorId(),
"name", mcpDto.getName(),
"description", mcpDto.getDescription(),
"installType", mcpDto.getInstallType().name(),
"mcpConfig", mcpDto.getMcpConfig().getServerConfig()))
.build());
McpConfig mcpConfig = new McpConfig();
mcpConfig.setId(mcpDto.getId());
mcpConfig.setDeployStatus(DeployStatusEnum.Deploying);
mcpConfigDomainService.update(mcpConfig);
}
@Override
public Mono<Boolean> asyncExecute(ScheduleTaskDto scheduleTask) {
return Mono.fromCallable(() -> execute1(scheduleTask));
}
public boolean execute1(ScheduleTaskDto scheduleTask) {
log.info("Deploy MCP {}", scheduleTask.getParams());
Map<String, Object> param = (Map<String, Object>) scheduleTask.getParams();
if (param.get("tenantId") == null) {
return true;
}
Long tenantId = Long.parseLong(param.get("tenantId").toString());
RequestContext.set(RequestContext.builder()
.tenantId(tenantId)
.build());
try {
return execute0(scheduleTask);
} finally {
RequestContext.remove();
}
}
private boolean execute0(ScheduleTaskDto scheduleTask) {
log.info("Deploy MCP {}", scheduleTask.getParams());
Map<String, Object> param = (Map<String, Object>) scheduleTask.getParams();
Long id = Long.parseLong(param.get("id").toString());
Long userId = Long.parseLong(param.get("userId").toString());
String serverConfig = param.get("mcpConfig").toString();
String mcpName = param.get("name").toString();
boolean isSSE = "SSE".equals(param.get("installType"));
UserDto userDto = userApplicationService.queryById(userId);
if (userDto == null) {
return true;
}
McpDeployStatusResponse deployStatus;
if (isSSE) {
deployStatus = new McpDeployStatusResponse();
deployStatus.setStatus(McpDeployStatusEnum.Ready);
} else {
deployStatus = mcpDeployRpcService.deploy(String.valueOf(id), serverConfig, McpPersistentTypeEnum.OneShot);
}
if (deployStatus.getStatus() == McpDeployStatusEnum.Ready) {
try {
updateAndSaveMcpConfig(id, serverConfig, isSSE, null);
notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder()
.scope(NotifyMessage.MessageScope.System)
.content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Success", mcpName))
.userIds(List.of(userId))
.build());
log.info("MCP service [{}] deployed successfully", mcpName);
return true;
} catch (Exception e) {
if (isSSE && scheduleTask.getExecTimes() + 1 >= scheduleTask.getMaxExecTimes()) {
deployStatus.setMessage(e.getMessage());
deployStatus.setStatus(McpDeployStatusEnum.Error);
}
}
}
if (deployStatus.getStatus() == McpDeployStatusEnum.Error) {
McpConfig mcpConfig = new McpConfig();
mcpConfig.setId(id);
mcpConfig.setDeployStatus(DeployStatusEnum.DeployFailed);
mcpConfigDomainService.update(mcpConfig);
notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder()
.scope(NotifyMessage.MessageScope.System)
.content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Failed", mcpName, deployStatus.getMessage()))
.userIds(List.of(userId))
.build());
return true;
}
checkAndSendFailedNotifyMessage(userDto, id, mcpName, scheduleTask);
return false;
}
private void checkAndSendFailedNotifyMessage(UserDto userDto, Long id, String mcpName, ScheduleTaskDto scheduleTask) {
if (scheduleTask.getExecTimes() + 1 >= scheduleTask.getMaxExecTimes()) {
McpConfig mcpConfig = new McpConfig();
mcpConfig.setId(id);
mcpConfig.setDeployStatus(DeployStatusEnum.DeployFailed);
mcpConfigDomainService.update(mcpConfig);
notifyMessageApplicationService.sendNotifyMessage(SendNotifyMessageDto.builder()
.scope(NotifyMessage.MessageScope.System)
.content(I18nUtil.systemMessage(userDto.getLangMap(), "Backend.Mcp.Deploy.Timeout", mcpName))
.userIds(List.of(userDto.getId()))
.build());
}
}
}

View File

@@ -0,0 +1,72 @@
package com.xspaceagi.mcp.application.service;
import com.alibaba.fastjson2.JSON;
import com.xspaceagi.mcp.adapter.domain.McpConfigDomainService;
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
import com.xspaceagi.mcp.sdk.dto.McpConfigDto;
import com.xspaceagi.system.sdk.service.ScheduleTaskApiService;
import com.xspaceagi.system.sdk.service.TaskExecuteService;
import com.xspaceagi.system.sdk.service.dto.ScheduleTaskDto;
import com.xspaceagi.system.spec.common.RequestContext;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.Map;
@Slf4j
@Service("sseMcpUpdateTaskService")
public class SSEMcpUpdateTaskServiceImpl extends AbstractDeployTaskService implements TaskExecuteService {
@Resource
private ScheduleTaskApiService scheduleTaskApiService;
@Resource
private McpConfigDomainService mcpConfigDomainService;
@PostConstruct
public void init() {
scheduleTaskApiService.start(ScheduleTaskDto.builder()
.taskId("sseMcpUpdateTaskService")
.beanId("sseMcpUpdateTaskService")
.maxExecTimes(Long.MAX_VALUE)
.cron(ScheduleTaskDto.Cron.EVERYDAY_0_2.getCron())
.params(Map.of())
.build());
}
@Override
public Mono<Boolean> asyncExecute(ScheduleTaskDto scheduleTask) {
return Mono.fromCallable(() -> execute0(scheduleTask));
}
public boolean execute0(ScheduleTaskDto scheduleTask) {
log.info("SSE update task running");
Long id = 0L;
List<McpConfig> mcpConfigs = mcpConfigDomainService.queryDeployedSSEMcpConfigList(id, 100);
while (!mcpConfigs.isEmpty()) {
for (McpConfig mcpConfig : mcpConfigs) {
McpConfigDto mcpConfigDto = JSON.parseObject(mcpConfig.getConfig(), McpConfigDto.class);
try {
RequestContext.set(RequestContext.builder()
.tenantId(mcpConfig.getTenantId())
.build());
updateAndSaveMcpConfig(mcpConfig.getId(), mcpConfigDto.getServerConfig(), true, mcpConfig.getModified());
log.info("SSE config updated {}, {}, {}", mcpConfig.getId(), mcpConfig.getName(), mcpConfigDto.getServerConfig());
} catch (Exception e) {
log.error("SSE config update failed {}", mcpConfigDto.getServerConfig(), e);
} finally {
RequestContext.remove();
}
id = mcpConfig.getId();
}
mcpConfigs = mcpConfigDomainService.queryDeployedSSEMcpConfigList(id, 100);
}
return false;
}
}

View File

@@ -0,0 +1,17 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>app-platform-mcp-domain</artifactId>
<name>app-platform-mcp-domain</name>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-infra</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,123 @@
package com.xspaceagi.core.domain.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xspaceagi.mcp.adapter.domain.McpConfigDomainService;
import com.xspaceagi.mcp.adapter.dto.McpPageQueryDto;
import com.xspaceagi.mcp.adapter.repository.McpConfigRepository;
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
import com.xspaceagi.mcp.sdk.enums.DeployStatusEnum;
import com.xspaceagi.mcp.sdk.enums.InstallTypeEnum;
import com.xspaceagi.system.spec.tenant.thread.TenantFunctions;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import java.util.List;
@Service
public class McpConfigDomainServiceImpl implements McpConfigDomainService {
@Resource
private McpConfigRepository mcpConfigRepository;
@Override
public void add(McpConfig mcpConfig) {
mcpConfigRepository.save(mcpConfig);
}
@Override
public void delete(Long id) {
Assert.notNull(id, "id must be non-null");
mcpConfigRepository.removeById(id);
}
@Override
public void update(McpConfig mcpConfig) {
Assert.notNull(mcpConfig.getId(), "id must be non-null");
mcpConfigRepository.updateById(mcpConfig);
}
@Override
public McpConfig getById(Long id) {
Assert.notNull(id, "id must be non-null");
return mcpConfigRepository.getById(id);
}
@Override
public McpConfig getBySpaceIdAndUid(Long spaceId, String uid) {
return mcpConfigRepository.getOne(new QueryWrapper<McpConfig>().eq("space_id", spaceId).eq("uid", uid));
}
@Override
public List<McpConfig> queryListBySpaceId(Long spaceId) {
LambdaQueryWrapper<McpConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(McpConfig::getSpaceId, spaceId);
//任务智能体代理mcp类型不用展示出来
queryWrapper.and(publishedLambdaQueryWrapper -> publishedLambdaQueryWrapper.ne(McpConfig::getCategory, "Proxy").or().isNull(McpConfig::getCategory));
queryWrapper.orderByDesc(McpConfig::getModified);
return mcpConfigRepository.list(queryWrapper);
}
@Override
public Page<McpConfig> queryDeployedMcpList(McpPageQueryDto mcpPageQueryDto) {
LambdaQueryWrapper<McpConfig> queryWrapper = new LambdaQueryWrapper<>();
if (mcpPageQueryDto.getSpaceId() == null) {
queryWrapper.eq(McpConfig::getSpaceId, -1L);
} else if (mcpPageQueryDto.getJustReturnSpaceData() != null && mcpPageQueryDto.getJustReturnSpaceData()) {
queryWrapper.eq(McpConfig::getSpaceId, mcpPageQueryDto.getSpaceId());
} else {
queryWrapper.in(McpConfig::getSpaceId, List.of(mcpPageQueryDto.getSpaceId(), -1L));
}
if (StringUtils.isNotBlank(mcpPageQueryDto.getKw())) {
queryWrapper.like(McpConfig::getName, mcpPageQueryDto.getKw());
}
if (mcpPageQueryDto.getCreatorIds() != null && !mcpPageQueryDto.getCreatorIds().isEmpty()) {
queryWrapper.in(McpConfig::getCreatorId, mcpPageQueryDto.getCreatorIds());
}
queryWrapper.and(publishedLambdaQueryWrapper -> publishedLambdaQueryWrapper.ne(McpConfig::getCategory, "Proxy").or().isNull(McpConfig::getCategory));
queryWrapper.notIn(McpConfig::getDeployStatus, DeployStatusEnum.Initialization, DeployStatusEnum.Stopped);
queryWrapper.isNotNull(McpConfig::getDeployedConfig);
queryWrapper.orderByDesc(McpConfig::getModified);
return mcpConfigRepository.page(new Page<>(mcpPageQueryDto.getPage(), mcpPageQueryDto.getPageSize()), queryWrapper);
}
@Override
public Page<McpConfig> queryDeployedMcpListForManage(McpPageQueryDto mcpPageQueryDto) {
LambdaQueryWrapper<McpConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(mcpPageQueryDto.getSpaceId() != null && mcpPageQueryDto.getSpaceId() > 0, McpConfig::getSpaceId, mcpPageQueryDto.getSpaceId());
queryWrapper.and(publishedLambdaQueryWrapper -> publishedLambdaQueryWrapper.ne(McpConfig::getCategory, "Proxy").or().isNull(McpConfig::getCategory));
queryWrapper.in(mcpPageQueryDto.getCreatorIds() != null && !mcpPageQueryDto.getCreatorIds().isEmpty(), McpConfig::getCreatorId, mcpPageQueryDto.getCreatorIds());
queryWrapper.notIn(McpConfig::getDeployStatus, DeployStatusEnum.Initialization, DeployStatusEnum.Stopped);
queryWrapper.like(mcpPageQueryDto.getKw() != null, McpConfig::getName, mcpPageQueryDto.getKw());
queryWrapper.isNotNull(McpConfig::getDeployedConfig);
queryWrapper.orderByDesc(McpConfig::getModified);
return mcpConfigRepository.page(new Page<>(mcpPageQueryDto.getPage(), mcpPageQueryDto.getPageSize()), queryWrapper);
}
@Override
public IPage<McpConfig> queryPage(McpConfig entityFilter, int pageNum, int pageSize) {
LambdaQueryWrapper<McpConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.setEntity(entityFilter);
return mcpConfigRepository.page(new Page<>(pageNum, pageSize), queryWrapper);
}
@Override
public List<McpConfig> queryDeployedSSEMcpConfigList(Long lastId, int size) {
LambdaQueryWrapper<McpConfig> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.gt(McpConfig::getId, lastId);
queryWrapper.eq(McpConfig::getDeployStatus, DeployStatusEnum.Deployed);
queryWrapper.eq(McpConfig::getInstallType, InstallTypeEnum.SSE);
queryWrapper.last("limit " + size);
queryWrapper.orderByAsc(McpConfig::getId);
return TenantFunctions.callWithIgnoreCheck(() -> mcpConfigRepository.list(queryWrapper));
}
@Override
public Long countTotalMcps() {
return mcpConfigRepository.count();
}
}

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-infra</artifactId>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-spec</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-adapter</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-agent-core-sdk</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-compose-sdk</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-knowledge-sdk</artifactId>
</dependency>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
</dependency>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-spring-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-eco-market-sdk</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-pricing-sdk</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>system-application</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,245 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
package com.xspaceagi.mcp.infra.client;
import lombok.extern.slf4j.Slf4j;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Flow;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.regex.Pattern;
/**
* A Server-Sent Events (SSE) client implementation using Java's Flow API for reactive
* stream processing. This client establishes a connection to an SSE endpoint and
* processes the incoming event stream, parsing SSE-formatted messages into structured
* events.
*
* <p>
* The client supports standard SSE event fields including:
* <ul>
* <li>event - The event type (defaults to "message" if not specified)</li>
* <li>id - The event ID</li>
* <li>data - The event payload data</li>
* </ul>
*
* <p>
* Events are delivered to a provided {@link SseEventHandler} which can process events and
* handle any errors that occur during the connection.
*
* @author Christian Tzolov
* @see SseEventHandler
* @see SseEvent
*/
@Slf4j
public class FlowSseClient {
private final HttpClient httpClient;
private final HttpRequest.Builder requestBuilder;
private Flow.Subscription subscription;
private String url;
private CompletableFuture<HttpResponse<Void>> future;
/**
* Pattern to extract the data content from SSE data field lines. Matches lines
* starting with "data:" and captures the remaining content.
*/
private static final Pattern EVENT_DATA_PATTERN = Pattern.compile("^data:(.+)$", Pattern.MULTILINE);
/**
* Pattern to extract the event ID from SSE id field lines. Matches lines starting
* with "id:" and captures the ID value.
*/
private static final Pattern EVENT_ID_PATTERN = Pattern.compile("^id:(.+)$", Pattern.MULTILINE);
/**
* Pattern to extract the event type from SSE event field lines. Matches lines
* starting with "event:" and captures the event type.
*/
private static final Pattern EVENT_TYPE_PATTERN = Pattern.compile("^event:(.+)$", Pattern.MULTILINE);
/**
* Record class representing a Server-Sent Event with its standard fields.
*
* @param id the event ID (may be null)
* @param type the event type (defaults to "message" if not specified in the stream)
* @param data the event payload data
*/
public static record SseEvent(String id, String type, String data) {
}
/**
* Interface for handling SSE events and errors. Implementations can process received
* events and handle any errors that occur during the SSE connection.
*/
public interface SseEventHandler {
/**
* Called when an SSE event is received.
*
* @param event the received SSE event containing id, type, and data
*/
void onEvent(SseEvent event);
/**
* Called when an error occurs during the SSE connection.
*
* @param error the error that occurred
*/
void onError(Throwable error);
}
/**
* Creates a new FlowSseClient with the specified HTTP client.
*
* @param httpClient the {@link HttpClient} instance to use for SSE connections
*/
public FlowSseClient(HttpClient httpClient) {
this(httpClient, HttpRequest.newBuilder());
}
/**
* Creates a new FlowSseClient with the specified HTTP client and request builder.
*
* @param httpClient the {@link HttpClient} instance to use for SSE connections
* @param requestBuilder the {@link HttpRequest.Builder} to use for SSE requests
*/
public FlowSseClient(HttpClient httpClient, HttpRequest.Builder requestBuilder) {
this.httpClient = httpClient;
this.requestBuilder = requestBuilder;
}
/**
* Subscribes to an SSE endpoint and processes the event stream.
*
* <p>
* This method establishes a connection to the specified URL and begins processing the
* SSE stream. Events are parsed and delivered to the provided event handler. The
* connection remains active until either an error occurs or the server closes the
* connection.
*
* @param url the SSE endpoint URL to connect to
* @param eventHandler the handler that will receive SSE events and error
* notifications
* @throws RuntimeException if the connection fails with a non-200 status code
*/
public void subscribe(String url, SseEventHandler eventHandler) {
this.url = url;
HttpRequest request = this.requestBuilder.uri(URI.create(url))
.header("Accept", "text/event-stream")
.header("Cache-Control", "no-cache")
.GET()
.build();
StringBuilder eventBuilder = new StringBuilder();
AtomicReference<String> currentEventId = new AtomicReference<>();
AtomicReference<String> currentEventType = new AtomicReference<>("message");
Flow.Subscriber<String> lineSubscriber = new Flow.Subscriber<>() {
@Override
public void onSubscribe(Flow.Subscription subscription) {
FlowSseClient.this.subscription = subscription;
subscription.request(Long.MAX_VALUE);
}
@Override
public void onNext(String line) {
if (line.isEmpty()) {
// Empty line means end of event
if (eventBuilder.length() > 0) {
String eventData = eventBuilder.toString();
SseEvent event = new SseEvent(currentEventId.get(), currentEventType.get(), eventData.trim());
eventHandler.onEvent(event);
eventBuilder.setLength(0);
}
} else {
if (line.startsWith("data:")) {
var matcher = EVENT_DATA_PATTERN.matcher(line);
if (matcher.find()) {
eventBuilder.append(matcher.group(1).trim()).append("\n");
}
} else if (line.startsWith("id:")) {
var matcher = EVENT_ID_PATTERN.matcher(line);
if (matcher.find()) {
currentEventId.set(matcher.group(1).trim());
}
} else if (line.startsWith("event:")) {
var matcher = EVENT_TYPE_PATTERN.matcher(line);
if (matcher.find()) {
currentEventType.set(matcher.group(1).trim());
}
}
}
subscription.request(1);
}
@Override
public void onError(Throwable throwable) {
eventHandler.onError(throwable);
try {
subscription.cancel();
} catch (Exception e) {
// Ignore
}
}
@Override
public void onComplete() {
// Handle any remaining event data
if (eventBuilder.length() > 0) {
String eventData = eventBuilder.toString();
SseEvent event = new SseEvent(currentEventId.get(), currentEventType.get(), eventData.trim());
eventHandler.onEvent(event);
}
try {
subscription.cancel();
} catch (Exception e) {
// Ignore
}
}
};
Function<Flow.Subscriber<String>, HttpResponse.BodySubscriber<Void>> subscriberFactory = subscriber -> HttpResponse.BodySubscribers
.fromLineSubscriber(subscriber);
future = this.httpClient.sendAsync(request, info -> subscriberFactory.apply(lineSubscriber));
future.thenAccept(response -> {
int status = response.statusCode();
if (status != 200 && status != 201 && status != 202 && status != 206) {
throw new RuntimeException("Failed to connect to SSE stream. Unexpected status code: " + status);
}
}).exceptionally(throwable -> {
eventHandler.onError(throwable);
return null;
});
}
public void close() {
log.debug("Closing FlowSseClient {}", this.url);
if (this.subscription != null) {
try {
this.subscription.cancel();
} catch (Exception e) {
// Ignore
}
}
if (this.future != null) {
try {
this.future.cancel(true);
} catch (Exception e) {
// Ignore
}
}
}
}

View File

@@ -0,0 +1,524 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
package com.xspaceagi.mcp.infra.client;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Server-Sent Events (SSE) implementation of the
* {@link io.modelcontextprotocol.spec.McpTransport} that follows the MCP HTTP with SSE
* transport specification, using Java's HttpClient.
*
* <p>
* This transport implementation establishes a bidirectional communication channel between
* client and server using SSE for server-to-client messages and HTTP POST requests for
* client-to-server messages. The transport:
* <ul>
* <li>Establishes an SSE connection to receive server messages</li>
* <li>Handles endpoint discovery through SSE events</li>
* <li>Manages message serialization/deserialization using Jackson</li>
* <li>Provides graceful connection termination</li>
* </ul>
*
* <p>
* The transport supports two types of SSE events:
* <ul>
* <li>'endpoint' - Contains the URL for sending client messages</li>
* <li>'message' - Contains JSON-RPC message payload</li>
* </ul>
*
* @author Christian Tzolov
* @see io.modelcontextprotocol.spec.McpTransport
* @see io.modelcontextprotocol.spec.McpClientTransport
*/
public class HttpClientSseClientTransport implements McpClientTransport {
private static final Logger logger = LoggerFactory.getLogger(HttpClientSseClientTransport.class);
/**
* SSE event type for JSON-RPC messages
*/
private static final String MESSAGE_EVENT_TYPE = "message";
/**
* SSE event type for endpoint discovery
*/
private static final String ENDPOINT_EVENT_TYPE = "endpoint";
/**
* Default SSE endpoint path
*/
private static final String DEFAULT_SSE_ENDPOINT = "/sse";
/**
* Base URI for the MCP server
*/
private final String baseUri;
/**
* SSE endpoint path
*/
private final String sseEndpoint;
/**
* SSE client for handling server-sent events. Uses the /sse endpoint
*/
private final FlowSseClient sseClient;
/**
* HTTP client for sending messages to the server. Uses HTTP POST over the message
* endpoint
*/
private final HttpClient httpClient;
/**
* HTTP request builder for building requests to send messages to the server
*/
private final HttpRequest.Builder requestBuilder;
/**
* JSON object mapper for message serialization/deserialization
*/
protected ObjectMapper objectMapper;
/**
* Flag indicating if the transport is in closing state
*/
private volatile boolean isClosing = false;
/**
* Latch for coordinating endpoint discovery
*/
private final CountDownLatch closeLatch = new CountDownLatch(1);
/**
* Holds the discovered message endpoint URL
*/
private final AtomicReference<String> messageEndpoint = new AtomicReference<>();
/**
* Holds the SSE connection future
*/
private final AtomicReference<CompletableFuture<Void>> connectionFuture = new AtomicReference<>();
/**
* Creates a new transport instance with default HTTP client and object mapper.
*
* @param baseUri the base URI of the MCP server
* @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead. This
* constructor will be removed in future versions.
*/
@Deprecated(forRemoval = true)
public HttpClientSseClientTransport(String baseUri) {
this(HttpClient.newBuilder(), baseUri, new ObjectMapper());
}
/**
* Creates a new transport instance with custom HTTP client builder and object mapper.
*
* @param clientBuilder the HTTP client builder to use
* @param baseUri the base URI of the MCP server
* @param objectMapper the object mapper for JSON serialization/deserialization
* @throws IllegalArgumentException if objectMapper or clientBuilder is null
* @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead. This
* constructor will be removed in future versions.
*/
@Deprecated(forRemoval = true)
public HttpClientSseClientTransport(HttpClient.Builder clientBuilder, String baseUri, ObjectMapper objectMapper) {
this(clientBuilder, baseUri, DEFAULT_SSE_ENDPOINT, objectMapper);
}
/**
* Creates a new transport instance with custom HTTP client builder and object mapper.
*
* @param clientBuilder the HTTP client builder to use
* @param baseUri the base URI of the MCP server
* @param sseEndpoint the SSE endpoint path
* @param objectMapper the object mapper for JSON serialization/deserialization
* @throws IllegalArgumentException if objectMapper or clientBuilder is null
* @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead. This
* constructor will be removed in future versions.
*/
@Deprecated(forRemoval = true)
public HttpClientSseClientTransport(HttpClient.Builder clientBuilder, String baseUri, String sseEndpoint,
ObjectMapper objectMapper) {
this(clientBuilder, HttpRequest.newBuilder(), baseUri, sseEndpoint, objectMapper);
}
/**
* Creates a new transport instance with custom HTTP client builder, object mapper,
* and headers.
*
* @param clientBuilder the HTTP client builder to use
* @param requestBuilder the HTTP request builder to use
* @param baseUri the base URI of the MCP server
* @param sseEndpoint the SSE endpoint path
* @param objectMapper the object mapper for JSON serialization/deserialization
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
* @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead. This
* constructor will be removed in future versions.
*/
@Deprecated(forRemoval = true)
public HttpClientSseClientTransport(HttpClient.Builder clientBuilder, HttpRequest.Builder requestBuilder,
String baseUri, String sseEndpoint, ObjectMapper objectMapper) {
this(clientBuilder.connectTimeout(Duration.ofSeconds(10)).build(), requestBuilder, baseUri, sseEndpoint,
objectMapper);
}
/**
* Creates a new transport instance with custom HTTP client builder, object mapper,
* and headers.
*
* @param httpClient the HTTP client to use
* @param requestBuilder the HTTP request builder to use
* @param baseUri the base URI of the MCP server
* @param sseEndpoint the SSE endpoint path
* @param objectMapper the object mapper for JSON serialization/deserialization
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
*/
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
String sseEndpoint, ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
Assert.hasText(baseUri, "baseUri must not be empty");
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
Assert.notNull(httpClient, "httpClient must not be null");
Assert.notNull(requestBuilder, "requestBuilder must not be null");
this.baseUri = baseUri;
this.sseEndpoint = sseEndpoint;
this.objectMapper = objectMapper;
this.httpClient = httpClient;
this.requestBuilder = requestBuilder;
this.sseClient = new FlowSseClient(this.httpClient, requestBuilder);
}
/**
* Creates a new builder for {@link HttpClientSseClientTransport}.
*
* @param baseUri the base URI of the MCP server
* @return a new builder instance
*/
public static Builder builder(String baseUri) {
return new Builder().baseUri(baseUri);
}
/**
* Builder for {@link HttpClientSseClientTransport}.
*/
public static class Builder {
private String baseUri;
private String sseEndpoint = DEFAULT_SSE_ENDPOINT;
private HttpClient.Builder clientBuilder = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10));
private HttpClient httpClient;
private ObjectMapper objectMapper = new ObjectMapper();
private HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.header("Content-Type", "application/json");
/**
* Creates a new builder instance.
*/
Builder() {
// Default constructor
}
/**
* Creates a new builder with the specified base URI.
*
* @param baseUri the base URI of the MCP server
* @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead.
* This constructor is deprecated and will be removed or made {@code protected} or
* {@code private} in a future release.
*/
@Deprecated(forRemoval = true)
public Builder(String baseUri) {
Assert.hasText(baseUri, "baseUri must not be empty");
this.baseUri = baseUri;
}
/**
* Sets the base URI.
*
* @param baseUri the base URI
* @return this builder
*/
Builder baseUri(String baseUri) {
Assert.hasText(baseUri, "baseUri must not be empty");
this.baseUri = baseUri;
return this;
}
/**
* Sets the SSE endpoint path.
*
* @param sseEndpoint the SSE endpoint path
* @return this builder
*/
public Builder sseEndpoint(String sseEndpoint) {
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
this.sseEndpoint = sseEndpoint;
return this;
}
/**
* Sets the HTTP client builder.
*
* @param clientBuilder the HTTP client builder
* @return this builder
*/
public Builder clientBuilder(HttpClient.Builder clientBuilder) {
Assert.notNull(clientBuilder, "clientBuilder must not be null");
this.clientBuilder = clientBuilder;
return this;
}
public Builder httpClient(HttpClient httpClient) {
Assert.notNull(httpClient, "httpClient must not be null");
this.httpClient = httpClient;
return this;
}
/**
* Customizes the HTTP client builder.
*
* @param clientCustomizer the consumer to customize the HTTP client builder
* @return this builder
*/
public Builder customizeClient(final Consumer<HttpClient.Builder> clientCustomizer) {
Assert.notNull(clientCustomizer, "clientCustomizer must not be null");
clientCustomizer.accept(clientBuilder);
return this;
}
/**
* Sets the HTTP request builder.
*
* @param requestBuilder the HTTP request builder
* @return this builder
*/
public Builder requestBuilder(HttpRequest.Builder requestBuilder) {
Assert.notNull(requestBuilder, "requestBuilder must not be null");
this.requestBuilder = requestBuilder;
return this;
}
/**
* Customizes the HTTP client builder.
*
* @param requestCustomizer the consumer to customize the HTTP request builder
* @return this builder
*/
public Builder customizeRequest(final Consumer<HttpRequest.Builder> requestCustomizer) {
Assert.notNull(requestCustomizer, "requestCustomizer must not be null");
requestCustomizer.accept(requestBuilder);
return this;
}
/**
* Sets the object mapper for JSON serialization/deserialization.
*
* @param objectMapper the object mapper
* @return this builder
*/
public Builder objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "objectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
/**
* Builds a new {@link HttpClientSseClientTransport} instance.
*
* @return a new transport instance
*/
public HttpClientSseClientTransport build() {
return new HttpClientSseClientTransport(httpClient != null ? httpClient : clientBuilder.build(), requestBuilder, baseUri, sseEndpoint,
objectMapper);
}
}
/**
* Establishes the SSE connection with the server and sets up message handling.
*
* <p>
* This method:
* <ul>
* <li>Initiates the SSE connection</li>
* <li>Handles endpoint discovery events</li>
* <li>Processes incoming JSON-RPC messages</li>
* </ul>
*
* @param handler the function to process received JSON-RPC messages
* @return a Mono that completes when the connection is established
*/
@Override
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
CompletableFuture<Void> future = new CompletableFuture<>();
connectionFuture.set(future);
sseClient.subscribe(this.baseUri + this.sseEndpoint, new FlowSseClient.SseEventHandler() {
@Override
public void onEvent(FlowSseClient.SseEvent event) {
if (isClosing) {
return;
}
try {
if (ENDPOINT_EVENT_TYPE.equals(event.type())) {
String endpoint = event.data();
messageEndpoint.set(endpoint);
closeLatch.countDown();
future.complete(null);
} else if (MESSAGE_EVENT_TYPE.equals(event.type())) {
JSONRPCMessage message;
try {
message = McpSchema.deserializeJsonRpcMessage(objectMapper, event.data());
} catch (Exception e) {
logger.error("Error deserializing JSON-RPC message", e);
handler.apply(Mono.error(e)).subscribe();
return;
}
handler.apply(Mono.just(message)).subscribe();
} else {
logger.error("Received unrecognized SSE event type: {}", event.type());
}
} catch (Exception e) {
logger.error("Error processing SSE event", e);
future.completeExceptionally(e);
}
}
@Override
public void onError(Throwable error) {
if (!isClosing) {
logger.error("SSE connection error", error);
future.completeExceptionally(error);
}
}
});
return Mono.fromFuture(future);
}
/**
* Sends a JSON-RPC message to the server.
*
* <p>
* This method waits for the message endpoint to be discovered before sending the
* message. The message is serialized to JSON and sent as an HTTP POST request.
*
* @param message the JSON-RPC message to send
* @return a Mono that completes when the message is sent
* @throws McpError if the message endpoint is not available or the wait times out
*/
@Override
public Mono<Void> sendMessage(JSONRPCMessage message) {
if (isClosing) {
logger.debug("Transport is closing");
return Mono.error(new McpError("isClosing"));
}
try {
if (!closeLatch.await(10, TimeUnit.SECONDS)) {
return Mono.error(new McpError("Failed to wait for the message endpoint"));
}
} catch (InterruptedException e) {
return Mono.error(new McpError("Failed to wait for the message endpoint"));
}
String endpoint = messageEndpoint.get();
if (endpoint == null) {
return Mono.error(new McpError("No message endpoint available"));
}
try {
String jsonText = this.objectMapper.writeValueAsString(message);
HttpRequest.Builder builder = requestBuilder.copy().setHeader("accept", "*/*");
HttpRequest request = builder.uri(URI.create(this.baseUri + endpoint))
.POST(HttpRequest.BodyPublishers.ofString(jsonText))
.build();
return Mono.fromFuture(
httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).thenAccept(response -> {
if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 202
&& response.statusCode() != 206) {
logger.error("Error sending message: {}", response.statusCode());
}
}));
} catch (IOException e) {
if (!isClosing) {
return Mono.error(new RuntimeException("Failed to serialize message", e));
}
return Mono.empty();
}
}
/**
* Gracefully closes the transport connection.
*
* <p>
* Sets the closing flag and cancels any pending connection future. This prevents new
* messages from being sent and allows ongoing operations to complete.
*
* @return a Mono that completes when the closing process is initiated
*/
@Override
public Mono<Void> closeGracefully() {
return Mono.fromRunnable(() -> {
isClosing = true;
CompletableFuture<Void> future = connectionFuture.get();
if (future != null && !future.isDone()) {
future.cancel(true);
}
if (sseClient != null) {
sseClient.close();
}
});
}
/**
* Unmarshal data to the specified type using the configured object mapper.
*
* @param data the data to unmarshal
* @param typeRef the type reference for the target type
* @param <T> the target type
* @return the unmarshalled object
*/
@Override
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
return this.objectMapper.convertValue(data, typeRef);
}
}

View File

@@ -0,0 +1,858 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.xspaceagi.mcp.infra.client;
import com.fasterxml.jackson.core.type.TypeReference;
import com.xspaceagi.system.spec.utils.TimeWheel;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.*;
import io.modelcontextprotocol.spec.McpTransport;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.Utils;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import java.time.Duration;
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.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
/**
* The Model Context Protocol (MCP) client implementation that provides asynchronous
* communication with MCP servers using Project Reactor's Mono and Flux types.
*
* <p>
* This client implements the MCP specification, enabling AI models to interact with
* external tools and resources through a standardized interface. Key features include:
* <ul>
* <li>Asynchronous communication using reactive programming patterns
* <li>Tool discovery and invocation for server-provided functionality
* <li>Resource access and management with URI-based addressing
* <li>Prompt template handling for standardized AI interactions
* <li>Real-time notifications for tools, resources, and prompts changes
* <li>Structured logging with configurable severity levels
* <li>Message sampling for AI model interactions
* </ul>
*
* <p>
* The client follows a lifecycle:
* <ol>
* <li>Initialization - Establishes connection and negotiates capabilities
* <li>Normal Operation - Handles requests and notifications
* <li>Graceful Shutdown - Ensures clean connection termination
* </ol>
*
* <p>
* This implementation uses Project Reactor for non-blocking operations, making it
* suitable for high-throughput scenarios and reactive applications. All operations return
* Mono or Flux types that can be composed into reactive pipelines.
*
* @author Dariusz Jędrzejczyk
* @author Christian Tzolov
* @see McpClient
* @see McpSchema
* @see McpClientSession
*/
@Slf4j
public class McpAsyncClient {
private static final Logger logger = LoggerFactory.getLogger(McpAsyncClient.class);
private static TypeReference<Void> VOID_TYPE_REFERENCE = new TypeReference<>() {
};
protected final Sinks.One<McpSchema.InitializeResult> initializedSink = Sinks.one();
private AtomicBoolean initialized = new AtomicBoolean(false);
/**
* The max timeout to await for the client-server connection to be initialized.
*/
private final Duration initializationTimeout;
/**
* The MCP session implementation that manages bidirectional JSON-RPC communication
* between clients and servers.
*/
private final McpClientSession mcpSession;
/**
* Client capabilities.
*/
private final McpSchema.ClientCapabilities clientCapabilities;
/**
* Client implementation information.
*/
private final McpSchema.Implementation clientInfo;
/**
* Server capabilities.
*/
private McpSchema.ServerCapabilities serverCapabilities;
/**
* Server implementation information.
*/
private McpSchema.Implementation serverInfo;
/**
* Roots define the boundaries of where servers can operate within the filesystem,
* allowing them to understand which directories and files they have access to.
* Servers can request the list of roots from supporting clients and receive
* notifications when that list changes.
*/
private final ConcurrentHashMap<String, Root> roots;
/**
* MCP provides a standardized way for servers to request LLM sampling ("completions"
* or "generations") from language models via clients. This flow allows clients to
* maintain control over model access, selection, and permissions while enabling
* servers to leverage AI capabilities—with no server API keys necessary. Servers can
* request text or image-based interactions and optionally include context from MCP
* servers in their prompts.
*/
private Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler;
/**
* Client transport implementation.
*/
private final McpTransport transport;
/**
* Supported protocol versions.
*/
private List<String> protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION);
private volatile boolean isClosed = false;
/**
* Create a new McpAsyncClient with the given transport and session request-response
* timeout.
*
* @param transport the transport to use.
* @param requestTimeout the session request-response timeout.
* @param initializationTimeout the max timeout to await for the client-server
* @param features the MCP Client supported features.
*/
McpAsyncClient(McpClientTransport transport, Duration requestTimeout, Duration initializationTimeout,
McpClientFeatures.Async features) {
Assert.notNull(transport, "Transport must not be null");
Assert.notNull(requestTimeout, "Request timeout must not be null");
Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
this.clientInfo = features.clientInfo();
this.clientCapabilities = features.clientCapabilities();
this.transport = transport;
this.roots = new ConcurrentHashMap<>(features.roots());
this.initializationTimeout = initializationTimeout;
// Request Handlers
Map<String, McpClientSession.RequestHandler<?>> requestHandlers = new HashMap<>();
// Roots List Request Handler
if (this.clientCapabilities.roots() != null) {
requestHandlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler());
}
// Sampling Handler
if (this.clientCapabilities.sampling() != null) {
if (features.samplingHandler() == null) {
throw new McpError("Sampling handler must not be null when client capabilities include sampling");
}
this.samplingHandler = features.samplingHandler();
requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
}
// Notification Handlers
Map<String, McpClientSession.NotificationHandler> notificationHandlers = new HashMap<>();
// Tools Change Notification
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumersFinal = new ArrayList<>();
toolsChangeConsumersFinal
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Tools changed: {}", notification)));
if (!Utils.isEmpty(features.toolsChangeConsumers())) {
toolsChangeConsumersFinal.addAll(features.toolsChangeConsumers());
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED,
asyncToolsChangeNotificationHandler(toolsChangeConsumersFinal));
// Resources Change Notification
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumersFinal = new ArrayList<>();
resourcesChangeConsumersFinal
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Resources changed: {}", notification)));
if (!Utils.isEmpty(features.resourcesChangeConsumers())) {
resourcesChangeConsumersFinal.addAll(features.resourcesChangeConsumers());
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED,
asyncResourcesChangeNotificationHandler(resourcesChangeConsumersFinal));
// Prompts Change Notification
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumersFinal = new ArrayList<>();
promptsChangeConsumersFinal
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Prompts changed: {}", notification)));
if (!Utils.isEmpty(features.promptsChangeConsumers())) {
promptsChangeConsumersFinal.addAll(features.promptsChangeConsumers());
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED,
asyncPromptsChangeNotificationHandler(promptsChangeConsumersFinal));
// Utility Logging Notification
List<Function<LoggingMessageNotification, Mono<Void>>> loggingConsumersFinal = new ArrayList<>();
loggingConsumersFinal.add((notification) -> Mono.fromRunnable(() -> logger.debug("Logging: {}", notification)));
if (!Utils.isEmpty(features.loggingConsumers())) {
loggingConsumersFinal.addAll(features.loggingConsumers());
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_MESSAGE,
asyncLoggingNotificationHandler(loggingConsumersFinal));
this.mcpSession = new McpClientSession(requestTimeout, transport, requestHandlers, notificationHandlers);
}
private void nextHeartbeat() {
if (isClosed) {
log.debug("MCP heartbeat ended, {}", serverInfo);
return;
}
TimeWheel.getInstance().schedule((res) -> {
if (!isClosed) {
log.debug("Sending MCP heartbeat, {}", serverInfo);
ping().timeout(Duration.ofSeconds(20))
.onErrorResume(throwable -> {
if (throwable instanceof TimeoutException) {
log.error("MCP heartbeat timeout", throwable);
return Mono.error(throwable);
}
return Mono.empty();
})
.doOnError(throwable -> {
log.error("MCP heartbeat failed {}", serverInfo, throwable);
close();
})
.doOnSuccess(ping -> nextHeartbeat()).subscribe();
} else {
log.debug("Session closed, MCP heartbeat ended, {}", serverInfo);
}
}, 10);
}
/**
* Get the server capabilities that define the supported features and functionality.
*
* @return The server capabilities
*/
public McpSchema.ServerCapabilities getServerCapabilities() {
return this.serverCapabilities;
}
/**
* Get the server implementation information.
*
* @return The server implementation details
*/
public McpSchema.Implementation getServerInfo() {
return this.serverInfo;
}
/**
* Check if the client-server connection is initialized.
*
* @return true if the client-server connection is initialized
*/
public boolean isInitialized() {
return this.initialized.get();
}
/**
* Get the client capabilities that define the supported features and functionality.
*
* @return The client capabilities
*/
public ClientCapabilities getClientCapabilities() {
return this.clientCapabilities;
}
/**
* Get the client implementation information.
*
* @return The client implementation details
*/
public McpSchema.Implementation getClientInfo() {
return this.clientInfo;
}
/**
* Closes the client connection immediately.
*/
public void close() {
this.mcpSession.close();
isClosed = true;
}
/**
* Gracefully closes the client connection.
*
* @return A Mono that completes when the connection is closed
*/
public Mono<Void> closeGracefully() {
return this.mcpSession.closeGracefully();
}
// --------------------------
// Initialization
// --------------------------
/**
* The initialization phase MUST be the first interaction between client and server.
* During this phase, the client and server:
* <ul>
* <li>Establish protocol version compatibility</li>
* <li>Exchange and negotiate capabilities</li>
* <li>Share implementation details</li>
* </ul>
* <br/>
* The client MUST initiate this phase by sending an initialize request containing:
* The protocol version the client supports, client's capabilities and clients
* implementation information.
* <p/>
* The server MUST respond with its own capabilities and information.
* <p/>
* After successful initialization, the client MUST send an initialized notification
* to indicate it is ready to begin normal operations.
*
* @return the initialize result.
* @see <a href=
* "https://github.com/modelcontextprotocol/specification/blob/main/docs/specification/basic/lifecycle.md#initialization">MCP
* Initialization Spec</a>
*/
public Mono<McpSchema.InitializeResult> initialize() {
String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
latestVersion,
this.clientCapabilities,
this.clientInfo); // @formatter:on
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest(McpSchema.METHOD_INITIALIZE,
initializeRequest, new TypeReference<McpSchema.InitializeResult>() {
});
return result.flatMap(initializeResult -> {
this.serverCapabilities = initializeResult.capabilities();
this.serverInfo = initializeResult.serverInfo();
logger.info("Server response with Protocol: {}, Capabilities: {}, Info: {} and Instructions {}",
initializeResult.protocolVersion(), initializeResult.capabilities(), initializeResult.serverInfo(),
initializeResult.instructions());
if (!this.protocolVersions.contains(initializeResult.protocolVersion())) {
return Mono.error(new McpError(
"Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
}
nextHeartbeat();
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null).doOnSuccess(v -> {
this.initialized.set(true);
this.initializedSink.tryEmitValue(initializeResult);
}).thenReturn(initializeResult);
});
}
/**
* Utility method to handle the common pattern of checking initialization before
* executing an operation.
*
* @param <T> The type of the result Mono
* @param actionName The action to perform if the client is initialized
* @param operation The operation to execute if the client is initialized
* @return A Mono that completes with the result of the operation
*/
private <T> Mono<T> withInitializationCheck(String actionName,
Function<McpSchema.InitializeResult, Mono<T>> operation) {
return this.initializedSink.asMono()
.timeout(this.initializationTimeout)
.onErrorResume(TimeoutException.class,
ex -> Mono.error(new McpError("Client must be initialized before " + actionName)))
.flatMap(operation);
}
// --------------------------
// Basic Utilities
// --------------------------
/**
* Sends a ping request to the server.
*
* @return A Mono that completes with the server's ping response
*/
public Mono<Object> ping() {
return this.withInitializationCheck("pinging the server", initializedResult -> this.mcpSession
.sendRequest(McpSchema.METHOD_PING, null, new TypeReference<Object>() {
}));
}
// --------------------------
// Roots
// --------------------------
/**
* Adds a new root to the client's root list.
*
* @param root The root to add.
* @return A Mono that completes when the root is added and notifications are sent.
*/
public Mono<Void> addRoot(Root root) {
if (root == null) {
return Mono.error(new McpError("Root must not be null"));
}
if (this.clientCapabilities.roots() == null) {
return Mono.error(new McpError("Client must be configured with roots capabilities"));
}
if (this.roots.containsKey(root.uri())) {
return Mono.error(new McpError("Root with uri '" + root.uri() + "' already exists"));
}
this.roots.put(root.uri(), root);
logger.debug("Added root: {}", root);
if (this.clientCapabilities.roots().listChanged()) {
if (this.isInitialized()) {
return this.rootsListChangedNotification();
} else {
logger.warn("Client is not initialized, ignore sending a roots list changed notification");
}
}
return Mono.empty();
}
/**
* Removes a root from the client's root list.
*
* @param rootUri The URI of the root to remove.
* @return A Mono that completes when the root is removed and notifications are sent.
*/
public Mono<Void> removeRoot(String rootUri) {
if (rootUri == null) {
return Mono.error(new McpError("Root uri must not be null"));
}
if (this.clientCapabilities.roots() == null) {
return Mono.error(new McpError("Client must be configured with roots capabilities"));
}
Root removed = this.roots.remove(rootUri);
if (removed != null) {
logger.debug("Removed Root: {}", rootUri);
if (this.clientCapabilities.roots().listChanged()) {
if (this.isInitialized()) {
return this.rootsListChangedNotification();
} else {
logger.warn("Client is not initialized, ignore sending a roots list changed notification");
}
}
return Mono.empty();
}
return Mono.error(new McpError("Root with uri '" + rootUri + "' not found"));
}
/**
* Manually sends a roots/list_changed notification. The addRoot and removeRoot
* methods automatically send the roots/list_changed notification if the client is in
* an initialized state.
*
* @return A Mono that completes when the notification is sent.
*/
public Mono<Void> rootsListChangedNotification() {
return this.withInitializationCheck("sending roots list changed notification",
initResult -> this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED));
}
private McpClientSession.RequestHandler<McpSchema.ListRootsResult> rootsListRequestHandler() {
return params -> {
@SuppressWarnings("unused")
McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
new TypeReference<McpSchema.PaginatedRequest>() {
});
List<Root> roots = this.roots.values().stream().toList();
return Mono.just(new McpSchema.ListRootsResult(roots));
};
}
// --------------------------
// Sampling
// --------------------------
private McpClientSession.RequestHandler<CreateMessageResult> samplingCreateMessageHandler() {
return params -> {
McpSchema.CreateMessageRequest request = transport.unmarshalFrom(params,
new TypeReference<McpSchema.CreateMessageRequest>() {
});
return this.samplingHandler.apply(request);
};
}
// --------------------------
// Tools
// --------------------------
private static final TypeReference<McpSchema.CallToolResult> CALL_TOOL_RESULT_TYPE_REF = new TypeReference<>() {
};
private static final TypeReference<McpSchema.ListToolsResult> LIST_TOOLS_RESULT_TYPE_REF = new TypeReference<>() {
};
/**
* Calls a tool provided by the server. Tools enable servers to expose executable
* functionality that can interact with external systems, perform computations, and
* take actions in the real world.
*
* @param callToolRequest The request containing the tool name and input parameters.
* @return A Mono that emits the result of the tool call, including the output and any
* errors.
* @see McpSchema.CallToolRequest
* @see McpSchema.CallToolResult
* @see #listTools()
*/
public Mono<McpSchema.CallToolResult> callTool(McpSchema.CallToolRequest callToolRequest) {
return this.withInitializationCheck("calling tools", initializedResult -> {
if (this.serverCapabilities.tools() == null) {
return Mono.error(new McpError("Server does not provide tools capability"));
}
return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
});
}
/**
* Retrieves the list of all tools provided by the server.
*
* @return A Mono that emits the list of tools result.
*/
public Mono<McpSchema.ListToolsResult> listTools() {
return this.listTools(null);
}
/**
* Retrieves a paginated list of tools provided by the server.
*
* @param cursor Optional pagination cursor from a previous list request
* @return A Mono that emits the list of tools result
*/
public Mono<McpSchema.ListToolsResult> listTools(String cursor) {
return this.withInitializationCheck("listing tools", initializedResult -> {
if (this.serverCapabilities.tools() == null) {
return Mono.error(new McpError("Server does not provide tools capability"));
}
return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
LIST_TOOLS_RESULT_TYPE_REF);
});
}
private McpClientSession.NotificationHandler asyncToolsChangeNotificationHandler(
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers) {
// TODO: params are not used yet
return params -> this.listTools()
.flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers)
.flatMap(consumer -> consumer.apply(listToolsResult.tools()))
.onErrorResume(error -> {
logger.error("Error handling tools list change notification", error);
return Mono.empty();
})
.then());
}
// --------------------------
// Resources
// --------------------------
private static final TypeReference<McpSchema.ListResourcesResult> LIST_RESOURCES_RESULT_TYPE_REF = new TypeReference<>() {
};
private static final TypeReference<McpSchema.ReadResourceResult> READ_RESOURCE_RESULT_TYPE_REF = new TypeReference<>() {
};
private static final TypeReference<McpSchema.ListResourceTemplatesResult> LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF = new TypeReference<>() {
};
/**
* Retrieves the list of all resources provided by the server. Resources represent any
* kind of UTF-8 encoded data that an MCP server makes available to clients, such as
* database records, API responses, log files, and more.
*
* @return A Mono that completes with the list of resources result.
* @see McpSchema.ListResourcesResult
* @see #readResource(McpSchema.Resource)
*/
public Mono<McpSchema.ListResourcesResult> listResources() {
return this.listResources(null);
}
/**
* Retrieves a paginated list of resources provided by the server. Resources represent
* any kind of UTF-8 encoded data that an MCP server makes available to clients, such
* as database records, API responses, log files, and more.
*
* @param cursor Optional pagination cursor from a previous list request.
* @return A Mono that completes with the list of resources result.
* @see McpSchema.ListResourcesResult
* @see #readResource(McpSchema.Resource)
*/
public Mono<McpSchema.ListResourcesResult> listResources(String cursor) {
return this.withInitializationCheck("listing resources", initializedResult -> {
if (this.serverCapabilities.resources() == null) {
return Mono.error(new McpError("Server does not provide the resources capability"));
}
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
LIST_RESOURCES_RESULT_TYPE_REF);
});
}
/**
* Reads the content of a specific resource identified by the provided Resource
* object. This method fetches the actual data that the resource represents.
*
* @param resource The resource to read, containing the URI that identifies the
* resource.
* @return A Mono that completes with the resource content.
* @see McpSchema.Resource
* @see McpSchema.ReadResourceResult
*/
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.Resource resource) {
return this.readResource(new McpSchema.ReadResourceRequest(resource.uri()));
}
/**
* Reads the content of a specific resource identified by the provided request. This
* method fetches the actual data that the resource represents.
*
* @param readResourceRequest The request containing the URI of the resource to read
* @return A Mono that completes with the resource content.
* @see McpSchema.ReadResourceRequest
* @see McpSchema.ReadResourceResult
*/
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceRequest readResourceRequest) {
return this.withInitializationCheck("reading resources", initializedResult -> {
if (this.serverCapabilities.resources() == null) {
return Mono.error(new McpError("Server does not provide the resources capability"));
}
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest,
READ_RESOURCE_RESULT_TYPE_REF);
});
}
/**
* Retrieves the list of all resource templates provided by the server. Resource
* templates allow servers to expose parameterized resources using URI templates,
* enabling dynamic resource access based on variable parameters.
*
* @return A Mono that completes with the list of resource templates result.
* @see McpSchema.ListResourceTemplatesResult
*/
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates() {
return this.listResourceTemplates(null);
}
/**
* Retrieves a paginated list of resource templates provided by the server. Resource
* templates allow servers to expose parameterized resources using URI templates,
* enabling dynamic resource access based on variable parameters.
*
* @param cursor Optional pagination cursor from a previous list request.
* @return A Mono that completes with the list of resource templates result.
* @see McpSchema.ListResourceTemplatesResult
*/
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) {
return this.withInitializationCheck("listing resource templates", initializedResult -> {
if (this.serverCapabilities.resources() == null) {
return Mono.error(new McpError("Server does not provide the resources capability"));
}
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST,
new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
});
}
/**
* Subscribes to changes in a specific resource. When the resource changes on the
* server, the client will receive notifications through the resources change
* notification handler.
*
* @param subscribeRequest The subscribe request containing the URI of the resource.
* @return A Mono that completes when the subscription is complete.
* @see McpSchema.SubscribeRequest
* @see #unsubscribeResource(McpSchema.UnsubscribeRequest)
*/
public Mono<Void> subscribeResource(McpSchema.SubscribeRequest subscribeRequest) {
return this.withInitializationCheck("subscribing to resources", initializedResult -> this.mcpSession
.sendRequest(McpSchema.METHOD_RESOURCES_SUBSCRIBE, subscribeRequest, VOID_TYPE_REFERENCE));
}
/**
* Cancels an existing subscription to a resource. After unsubscribing, the client
* will no longer receive notifications when the resource changes.
*
* @param unsubscribeRequest The unsubscribe request containing the URI of the
* resource.
* @return A Mono that completes when the unsubscription is complete.
* @see McpSchema.UnsubscribeRequest
* @see #subscribeResource(McpSchema.SubscribeRequest)
*/
public Mono<Void> unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) {
return this.withInitializationCheck("unsubscribing from resources", initializedResult -> this.mcpSession
.sendRequest(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, unsubscribeRequest, VOID_TYPE_REFERENCE));
}
private McpClientSession.NotificationHandler asyncResourcesChangeNotificationHandler(
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers) {
return params -> listResources().flatMap(listResourcesResult -> Flux.fromIterable(resourcesChangeConsumers)
.flatMap(consumer -> consumer.apply(listResourcesResult.resources()))
.onErrorResume(error -> {
logger.error("Error handling resources list change notification", error);
return Mono.empty();
})
.then());
}
// --------------------------
// Prompts
// --------------------------
private static final TypeReference<McpSchema.ListPromptsResult> LIST_PROMPTS_RESULT_TYPE_REF = new TypeReference<>() {
};
private static final TypeReference<McpSchema.GetPromptResult> GET_PROMPT_RESULT_TYPE_REF = new TypeReference<>() {
};
/**
* Retrieves the list of all prompts provided by the server.
*
* @return A Mono that completes with the list of prompts result.
* @see McpSchema.ListPromptsResult
* @see #getPrompt(GetPromptRequest)
*/
public Mono<ListPromptsResult> listPrompts() {
return this.listPrompts(null);
}
/**
* Retrieves a paginated list of prompts provided by the server.
*
* @param cursor Optional pagination cursor from a previous list request
* @return A Mono that completes with the list of prompts result.
* @see McpSchema.ListPromptsResult
* @see #getPrompt(GetPromptRequest)
*/
public Mono<ListPromptsResult> listPrompts(String cursor) {
return this.withInitializationCheck("listing prompts", initializedResult -> this.mcpSession
.sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF));
}
/**
* Retrieves a specific prompt by its ID. This provides the complete prompt template
* including all parameters and instructions for generating AI content.
*
* @param getPromptRequest The request containing the ID of the prompt to retrieve.
* @return A Mono that completes with the prompt result.
* @see McpSchema.GetPromptRequest
* @see McpSchema.GetPromptResult
* @see #listPrompts()
*/
public Mono<GetPromptResult> getPrompt(GetPromptRequest getPromptRequest) {
return this.withInitializationCheck("getting prompts", initializedResult -> this.mcpSession
.sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF));
}
private McpClientSession.NotificationHandler asyncPromptsChangeNotificationHandler(
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers) {
return params -> listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers)
.flatMap(consumer -> consumer.apply(listPromptsResult.prompts()))
.onErrorResume(error -> {
logger.error("Error handling prompts list change notification", error);
return Mono.empty();
})
.then());
}
// --------------------------
// Logging
// --------------------------
/**
* Create a notification handler for logging notifications from the server. This
* handler automatically distributes logging messages to all registered consumers.
*
* @param loggingConsumers List of consumers that will be notified when a logging
* message is received. Each consumer receives the logging message notification.
* @return A NotificationHandler that processes log notifications by distributing the
* message to all registered consumers
*/
private McpClientSession.NotificationHandler asyncLoggingNotificationHandler(
List<Function<LoggingMessageNotification, Mono<Void>>> loggingConsumers) {
return params -> {
McpSchema.LoggingMessageNotification loggingMessageNotification = transport.unmarshalFrom(params,
new TypeReference<McpSchema.LoggingMessageNotification>() {
});
return Flux.fromIterable(loggingConsumers)
.flatMap(consumer -> consumer.apply(loggingMessageNotification))
.then();
};
}
/**
* Sets the minimum logging level for messages received from the server. The client
* will only receive log messages at or above the specified severity level.
*
* @param loggingLevel The minimum logging level to receive.
* @return A Mono that completes when the logging level is set.
* @see McpSchema.LoggingLevel
*/
public Mono<Void> setLoggingLevel(LoggingLevel loggingLevel) {
if (loggingLevel == null) {
return Mono.error(new McpError("Logging level must not be null"));
}
return this.withInitializationCheck("setting logging level", initializedResult -> {
var params = new McpSchema.SetLevelRequest(loggingLevel);
return this.mcpSession.sendRequest(McpSchema.METHOD_LOGGING_SET_LEVEL, params, new TypeReference<Object>() {
}).then();
});
}
/**
* This method is package-private and used for test only. Should not be called by user
* code.
*
* @param protocolVersions the Client supported protocol versions.
*/
void setProtocolVersions(List<String> protocolVersions) {
this.protocolVersions = protocolVersions;
}
}

View File

@@ -0,0 +1,39 @@
package com.xspaceagi.mcp.infra.client;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import reactor.core.publisher.Mono;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class McpAsyncClientWrapper {
private HttpClientSseClientTransport httpClientSseClientTransport;
private McpAsyncClient client;
public void close() {
if (client != null) {
client.close();
}
if (httpClientSseClientTransport != null) {
httpClientSseClientTransport.close();
}
}
public Mono<Void> closeGracefully() {
return Mono.create(sink -> {
if (client != null) {
client.close();
}
if (httpClientSseClientTransport != null) {
httpClientSseClientTransport.close();
}
sink.success();
});
}
}

View File

@@ -0,0 +1,593 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.xspaceagi.mcp.infra.client;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.*;
import io.modelcontextprotocol.spec.McpTransport;
import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Factory class for creating Model Context Protocol (MCP) clients. MCP is a protocol that
* enables AI models to interact with external tools and resources through a standardized
* interface.
*
* <p>
* This class serves as the main entry point for establishing connections with MCP
* servers, implementing the client-side of the MCP specification. The protocol follows a
* client-server architecture where:
* <ul>
* <li>The client (this implementation) initiates connections and sends requests
* <li>The server responds to requests and provides access to tools and resources
* <li>Communication occurs through a transport layer (e.g., stdio, SSE) using JSON-RPC
* 2.0
* </ul>
*
* <p>
* The class provides factory methods to create either:
* <ul>
* <li>{@link McpAsyncClient} for non-blocking operations with CompletableFuture responses
* </ul>
*
* <p>
* Example of creating a basic synchronous client: <pre>{@code
* McpClient.sync(transport)
* .requestTimeout(Duration.ofSeconds(5))
* .build();
* }</pre>
*
* Example of creating a basic asynchronous client: <pre>{@code
* McpClient.async(transport)
* .requestTimeout(Duration.ofSeconds(5))
* .build();
* }</pre>
*
* <p>
* Example with advanced asynchronous configuration: <pre>{@code
* McpClient.async(transport)
* .requestTimeout(Duration.ofSeconds(10))
* .capabilities(new ClientCapabilities(...))
* .clientInfo(new Implementation("My Client", "1.0.0"))
* .roots(new Root("file://workspace", "Workspace Files"))
* .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> System.out.println("Tools updated: " + tools)))
* .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> System.out.println("Resources updated: " + resources)))
* .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> System.out.println("Prompts updated: " + prompts)))
* .loggingConsumer(message -> Mono.fromRunnable(() -> System.out.println("Log message: " + message)))
* .build();
* }</pre>
*
* <p>
* The client supports:
* <ul>
* <li>Tool discovery and invocation
* <li>Resource access and management
* <li>Prompt template handling
* <li>Real-time updates through change consumers
* <li>Custom sampling strategies
* <li>Structured logging with severity levels
* </ul>
*
* <p>
* The client supports structured logging through the MCP logging utility:
* <ul>
* <li>Eight severity levels from DEBUG to EMERGENCY
* <li>Optional logger name categorization
* <li>Configurable logging consumers
* <li>Server-controlled minimum log level
* </ul>
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
* @see McpAsyncClient
* @see McpTransport
*/
public interface McpClient {
/**
* Start building a synchronous MCP client with the specified transport layer. The
* synchronous MCP client provides blocking operations. Synchronous clients wait for
* each operation to complete before returning, making them simpler to use but
* potentially less performant for concurrent operations. The transport layer handles
* the low-level communication between client and server using protocols like stdio or
* Server-Sent Events (SSE).
* @param transport The transport layer implementation for MCP communication. Common
* implementations include {@code StdioClientTransport} for stdio-based communication
* and {@code SseClientTransport} for SSE-based communication.
* @return A new builder instance for configuring the client
* @throws IllegalArgumentException if transport is null
*/
static SyncSpec sync(McpClientTransport transport) {
return new SyncSpec(transport);
}
/**
* Start building an asynchronous MCP client with the specified transport layer. The
* asynchronous MCP client provides non-blocking operations. Asynchronous clients
* return reactive primitives (Mono/Flux) immediately, allowing for concurrent
* operations and reactive programming patterns. The transport layer handles the
* low-level communication between client and server using protocols like stdio or
* Server-Sent Events (SSE).
* @param transport The transport layer implementation for MCP communication. Common
* implementations include {@code StdioClientTransport} for stdio-based communication
* and {@code SseClientTransport} for SSE-based communication.
* @return A new builder instance for configuring the client
* @throws IllegalArgumentException if transport is null
*/
static AsyncSpec async(McpClientTransport transport) {
return new AsyncSpec(transport);
}
/**
* Synchronous client specification. This class follows the builder pattern to provide
* a fluent API for setting up clients with custom configurations.
*
* <p>
* The builder supports configuration of:
* <ul>
* <li>Transport layer for client-server communication
* <li>Request timeouts for operation boundaries
* <li>Client capabilities for feature negotiation
* <li>Client implementation details for version tracking
* <li>Root URIs for resource access
* <li>Change notification handlers for tools, resources, and prompts
* <li>Custom message sampling logic
* </ul>
*/
class SyncSpec {
private final McpClientTransport transport;
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
private Duration initializationTimeout = Duration.ofSeconds(20);
private ClientCapabilities capabilities;
private Implementation clientInfo = new Implementation("Java SDK MCP Client", "1.0.0");
private final Map<String, Root> roots = new HashMap<>();
private final List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers = new ArrayList<>();
private final List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers = new ArrayList<>();
private final List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers = new ArrayList<>();
private final List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers = new ArrayList<>();
private Function<CreateMessageRequest, CreateMessageResult> samplingHandler;
private SyncSpec(McpClientTransport transport) {
Assert.notNull(transport, "Transport must not be null");
this.transport = transport;
}
/**
* Sets the duration to wait for server responses before timing out requests. This
* timeout applies to all requests made through the client, including tool calls,
* resource access, and prompt operations.
* @param requestTimeout The duration to wait before timing out requests. Must not
* be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if requestTimeout is null
*/
public SyncSpec requestTimeout(Duration requestTimeout) {
Assert.notNull(requestTimeout, "Request timeout must not be null");
this.requestTimeout = requestTimeout;
return this;
}
/**
* @param initializationTimeout The duration to wait for the initializaiton
* lifecycle step to complete.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if initializationTimeout is null
*/
public SyncSpec initializationTimeout(Duration initializationTimeout) {
Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
this.initializationTimeout = initializationTimeout;
return this;
}
/**
* Sets the client capabilities that will be advertised to the server during
* connection initialization. Capabilities define what features the client
* supports, such as tool execution, resource access, and prompt handling.
* @param capabilities The client capabilities configuration. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if capabilities is null
*/
public SyncSpec capabilities(ClientCapabilities capabilities) {
Assert.notNull(capabilities, "Capabilities must not be null");
this.capabilities = capabilities;
return this;
}
/**
* Sets the client implementation information that will be shared with the server
* during connection initialization. This helps with version compatibility and
* debugging.
* @param clientInfo The client implementation details including name and version.
* Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if clientInfo is null
*/
public SyncSpec clientInfo(Implementation clientInfo) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
return this;
}
/**
* Sets the root URIs that this client can access. Roots define the base URIs for
* resources that the client can request from the server. For example, a root
* might be "file://workspace" for accessing workspace files.
* @param roots A list of root definitions. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if roots is null
*/
public SyncSpec roots(List<Root> roots) {
Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
this.roots.put(root.uri(), root);
}
return this;
}
/**
* Sets the root URIs that this client can access, using a varargs parameter for
* convenience. This is an alternative to {@link #roots(List)}.
* @param roots An array of root definitions. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if roots is null
* @see #roots(List)
*/
public SyncSpec roots(Root... roots) {
Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
this.roots.put(root.uri(), root);
}
return this;
}
/**
* Sets a custom sampling handler for processing message creation requests. The
* sampling handler can modify or validate messages before they are sent to the
* server, enabling custom processing logic.
* @param samplingHandler A function that processes message requests and returns
* results. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if samplingHandler is null
*/
public SyncSpec sampling(Function<CreateMessageRequest, CreateMessageResult> samplingHandler) {
Assert.notNull(samplingHandler, "Sampling handler must not be null");
this.samplingHandler = samplingHandler;
return this;
}
/**
* Adds a consumer to be notified when the available tools change. This allows the
* client to react to changes in the server's tool capabilities, such as tools
* being added or removed.
* @param toolsChangeConsumer A consumer that receives the updated list of
* available tools. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if toolsChangeConsumer is null
*/
public SyncSpec toolsChangeConsumer(Consumer<List<McpSchema.Tool>> toolsChangeConsumer) {
Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null");
this.toolsChangeConsumers.add(toolsChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when the available resources change. This allows
* the client to react to changes in the server's resource availability, such as
* files being added or removed.
* @param resourcesChangeConsumer A consumer that receives the updated list of
* available resources. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if resourcesChangeConsumer is null
*/
public SyncSpec resourcesChangeConsumer(Consumer<List<McpSchema.Resource>> resourcesChangeConsumer) {
Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null");
this.resourcesChangeConsumers.add(resourcesChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when the available prompts change. This allows
* the client to react to changes in the server's prompt templates, such as new
* templates being added or existing ones being modified.
* @param promptsChangeConsumer A consumer that receives the updated list of
* available prompts. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if promptsChangeConsumer is null
*/
public SyncSpec promptsChangeConsumer(Consumer<List<McpSchema.Prompt>> promptsChangeConsumer) {
Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null");
this.promptsChangeConsumers.add(promptsChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when logging messages are received from the
* server. This allows the client to react to log messages, such as warnings or
* errors, that are sent by the server.
* @param loggingConsumer A consumer that receives logging messages. Must not be
* null.
* @return This builder instance for method chaining
*/
public SyncSpec loggingConsumer(Consumer<McpSchema.LoggingMessageNotification> loggingConsumer) {
Assert.notNull(loggingConsumer, "Logging consumer must not be null");
this.loggingConsumers.add(loggingConsumer);
return this;
}
/**
* Adds multiple consumers to be notified when logging messages are received from
* the server. This allows the client to react to log messages, such as warnings
* or errors, that are sent by the server.
* @param loggingConsumers A list of consumers that receive logging messages. Must
* not be null.
* @return This builder instance for method chaining
*/
public SyncSpec loggingConsumers(List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers) {
Assert.notNull(loggingConsumers, "Logging consumers must not be null");
this.loggingConsumers.addAll(loggingConsumers);
return this;
}
}
/**
* Asynchronous client specification. This class follows the builder pattern to
* provide a fluent API for setting up clients with custom configurations.
*
* <p>
* The builder supports configuration of:
* <ul>
* <li>Transport layer for client-server communication
* <li>Request timeouts for operation boundaries
* <li>Client capabilities for feature negotiation
* <li>Client implementation details for version tracking
* <li>Root URIs for resource access
* <li>Change notification handlers for tools, resources, and prompts
* <li>Custom message sampling logic
* </ul>
*/
class AsyncSpec {
private final McpClientTransport transport;
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
private Duration initializationTimeout = Duration.ofSeconds(20);
private ClientCapabilities capabilities;
private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1");
private final Map<String, Root> roots = new HashMap<>();
private final List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers = new ArrayList<>();
private final List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers = new ArrayList<>();
private final List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers = new ArrayList<>();
private final List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers = new ArrayList<>();
private Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler;
private AsyncSpec(McpClientTransport transport) {
Assert.notNull(transport, "Transport must not be null");
this.transport = transport;
}
/**
* Sets the duration to wait for server responses before timing out requests. This
* timeout applies to all requests made through the client, including tool calls,
* resource access, and prompt operations.
* @param requestTimeout The duration to wait before timing out requests. Must not
* be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if requestTimeout is null
*/
public AsyncSpec requestTimeout(Duration requestTimeout) {
Assert.notNull(requestTimeout, "Request timeout must not be null");
this.requestTimeout = requestTimeout;
return this;
}
/**
* @param initializationTimeout The duration to wait for the initializaiton
* lifecycle step to complete.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if initializationTimeout is null
*/
public AsyncSpec initializationTimeout(Duration initializationTimeout) {
Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
this.initializationTimeout = initializationTimeout;
return this;
}
/**
* Sets the client capabilities that will be advertised to the server during
* connection initialization. Capabilities define what features the client
* supports, such as tool execution, resource access, and prompt handling.
* @param capabilities The client capabilities configuration. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if capabilities is null
*/
public AsyncSpec capabilities(ClientCapabilities capabilities) {
Assert.notNull(capabilities, "Capabilities must not be null");
this.capabilities = capabilities;
return this;
}
/**
* Sets the client implementation information that will be shared with the server
* during connection initialization. This helps with version compatibility and
* debugging.
* @param clientInfo The client implementation details including name and version.
* Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if clientInfo is null
*/
public AsyncSpec clientInfo(Implementation clientInfo) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
return this;
}
/**
* Sets the root URIs that this client can access. Roots define the base URIs for
* resources that the client can request from the server. For example, a root
* might be "file://workspace" for accessing workspace files.
* @param roots A list of root definitions. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if roots is null
*/
public AsyncSpec roots(List<Root> roots) {
Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
this.roots.put(root.uri(), root);
}
return this;
}
/**
* Sets the root URIs that this client can access, using a varargs parameter for
* convenience. This is an alternative to {@link #roots(List)}.
* @param roots An array of root definitions. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if roots is null
* @see #roots(List)
*/
public AsyncSpec roots(Root... roots) {
Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
this.roots.put(root.uri(), root);
}
return this;
}
/**
* Sets a custom sampling handler for processing message creation requests. The
* sampling handler can modify or validate messages before they are sent to the
* server, enabling custom processing logic.
* @param samplingHandler A function that processes message requests and returns
* results. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if samplingHandler is null
*/
public AsyncSpec sampling(Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler) {
Assert.notNull(samplingHandler, "Sampling handler must not be null");
this.samplingHandler = samplingHandler;
return this;
}
/**
* Adds a consumer to be notified when the available tools change. This allows the
* client to react to changes in the server's tool capabilities, such as tools
* being added or removed.
* @param toolsChangeConsumer A consumer that receives the updated list of
* available tools. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if toolsChangeConsumer is null
*/
public AsyncSpec toolsChangeConsumer(Function<List<McpSchema.Tool>, Mono<Void>> toolsChangeConsumer) {
Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null");
this.toolsChangeConsumers.add(toolsChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when the available resources change. This allows
* the client to react to changes in the server's resource availability, such as
* files being added or removed.
* @param resourcesChangeConsumer A consumer that receives the updated list of
* available resources. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if resourcesChangeConsumer is null
*/
public AsyncSpec resourcesChangeConsumer(
Function<List<McpSchema.Resource>, Mono<Void>> resourcesChangeConsumer) {
Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null");
this.resourcesChangeConsumers.add(resourcesChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when the available prompts change. This allows
* the client to react to changes in the server's prompt templates, such as new
* templates being added or existing ones being modified.
* @param promptsChangeConsumer A consumer that receives the updated list of
* available prompts. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if promptsChangeConsumer is null
*/
public AsyncSpec promptsChangeConsumer(Function<List<McpSchema.Prompt>, Mono<Void>> promptsChangeConsumer) {
Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null");
this.promptsChangeConsumers.add(promptsChangeConsumer);
return this;
}
/**
* Adds a consumer to be notified when logging messages are received from the
* server. This allows the client to react to log messages, such as warnings or
* errors, that are sent by the server.
* @param loggingConsumer A consumer that receives logging messages. Must not be
* null.
* @return This builder instance for method chaining
*/
public AsyncSpec loggingConsumer(Function<McpSchema.LoggingMessageNotification, Mono<Void>> loggingConsumer) {
Assert.notNull(loggingConsumer, "Logging consumer must not be null");
this.loggingConsumers.add(loggingConsumer);
return this;
}
/**
* Adds multiple consumers to be notified when logging messages are received from
* the server. This allows the client to react to log messages, such as warnings
* or errors, that are sent by the server.
* @param loggingConsumers A list of consumers that receive logging messages. Must
* not be null.
* @return This builder instance for method chaining
*/
public AsyncSpec loggingConsumers(
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers) {
Assert.notNull(loggingConsumers, "Logging consumers must not be null");
this.loggingConsumers.addAll(loggingConsumers);
return this;
}
/**
* Create an instance of {@link McpAsyncClient} with the provided configurations
* or sensible defaults.
* @return a new instance of {@link McpAsyncClient}.
*/
public McpAsyncClient build() {
return new McpAsyncClient(this.transport, this.requestTimeout, this.initializationTimeout,
new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots,
this.toolsChangeConsumers, this.resourcesChangeConsumers, this.promptsChangeConsumers,
this.loggingConsumers, this.samplingHandler));
}
}
}

View File

@@ -0,0 +1,201 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.xspaceagi.mcp.infra.client;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.Utils;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
/**
* Representation of features and capabilities for Model Context Protocol (MCP) clients.
* This class provides two record types for managing client features:
* <ul>
* <li>{@link Async} for non-blocking operations with Project Reactor's Mono responses
* <li>{@link Sync} for blocking operations with direct responses
* </ul>
*
* <p>
* Each feature specification includes:
* <ul>
* <li>Client implementation information and capabilities
* <li>Root URI mappings for resource access
* <li>Change notification handlers for tools, resources, and prompts
* <li>Logging message consumers
* <li>Message sampling handlers for request processing
* </ul>
*
* <p>
* The class supports conversion between synchronous and asynchronous specifications
* through the {@link Async#fromSync} method, which ensures proper handling of blocking
* operations in non-blocking contexts by scheduling them on a bounded elastic scheduler.
*
* @author Dariusz Jędrzejczyk
* @see McpClient
* @see McpSchema.Implementation
* @see McpSchema.ClientCapabilities
*/
class McpClientFeatures {
/**
* Asynchronous client features specification providing the capabilities and request
* and notification handlers.
*
* @param clientInfo the client implementation information.
* @param clientCapabilities the client capabilities.
* @param roots the roots.
* @param toolsChangeConsumers the tools change consumers.
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param samplingHandler the sampling handler.
*/
record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers,
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers,
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler) {
/**
* Create an instance and validate the arguments.
* @param clientCapabilities the client capabilities.
* @param roots the roots.
* @param toolsChangeConsumers the tools change consumers.
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param samplingHandler the sampling handler.
*/
public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots,
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers,
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers,
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities
: new McpSchema.ClientCapabilities(null,
!Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null,
samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null);
this.roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>();
this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of();
this.resourcesChangeConsumers = resourcesChangeConsumers != null ? resourcesChangeConsumers : List.of();
this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of();
this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of();
this.samplingHandler = samplingHandler;
}
/**
* Convert a synchronous specification into an asynchronous one and provide
* blocking code offloading to prevent accidental blocking of the non-blocking
* transport.
* @param syncSpec a potentially blocking, synchronous specification.
* @return a specification which is protected from blocking calls specified by the
* user.
*/
public static Async fromSync(Sync syncSpec) {
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers = new ArrayList<>();
for (Consumer<List<McpSchema.Tool>> consumer : syncSpec.toolsChangeConsumers()) {
toolsChangeConsumers.add(t -> Mono.<Void>fromRunnable(() -> consumer.accept(t))
.subscribeOn(Schedulers.boundedElastic()));
}
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers = new ArrayList<>();
for (Consumer<List<McpSchema.Resource>> consumer : syncSpec.resourcesChangeConsumers()) {
resourcesChangeConsumers.add(r -> Mono.<Void>fromRunnable(() -> consumer.accept(r))
.subscribeOn(Schedulers.boundedElastic()));
}
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers = new ArrayList<>();
for (Consumer<List<McpSchema.Prompt>> consumer : syncSpec.promptsChangeConsumers()) {
promptsChangeConsumers.add(p -> Mono.<Void>fromRunnable(() -> consumer.accept(p))
.subscribeOn(Schedulers.boundedElastic()));
}
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers = new ArrayList<>();
for (Consumer<McpSchema.LoggingMessageNotification> consumer : syncSpec.loggingConsumers()) {
loggingConsumers.add(l -> Mono.<Void>fromRunnable(() -> consumer.accept(l))
.subscribeOn(Schedulers.boundedElastic()));
}
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler = r -> Mono
.fromCallable(() -> syncSpec.samplingHandler().apply(r))
.subscribeOn(Schedulers.boundedElastic());
return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(),
toolsChangeConsumers, resourcesChangeConsumers, promptsChangeConsumers, loggingConsumers,
samplingHandler);
}
}
/**
* Synchronous client features specification providing the capabilities and request
* and notification handlers.
*
* @param clientInfo the client implementation information.
* @param clientCapabilities the client capabilities.
* @param roots the roots.
* @param toolsChangeConsumers the tools change consumers.
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param samplingHandler the sampling handler.
*/
public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers,
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler) {
/**
* Create an instance and validate the arguments.
* @param clientInfo the client implementation information.
* @param clientCapabilities the client capabilities.
* @param roots the roots.
* @param toolsChangeConsumers the tools change consumers.
* @param resourcesChangeConsumers the resources change consumers.
* @param promptsChangeConsumers the prompts change consumers.
* @param loggingConsumers the logging consumers.
* @param samplingHandler the sampling handler.
*/
public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers,
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities
: new McpSchema.ClientCapabilities(null,
!Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null,
samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null);
this.roots = roots != null ? new HashMap<>(roots) : new HashMap<>();
this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of();
this.resourcesChangeConsumers = resourcesChangeConsumers != null ? resourcesChangeConsumers : List.of();
this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of();
this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of();
this.samplingHandler = samplingHandler;
}
}
}

View File

@@ -0,0 +1,317 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.xspaceagi.mcp.infra.client;
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSession;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.Disposable;
import reactor.core.publisher.Mono;
import reactor.core.publisher.MonoSink;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* Default implementation of the MCP (Model Context Protocol) session that manages
* bidirectional JSON-RPC communication between clients and servers. This implementation
* follows the MCP specification for message exchange and transport handling.
*
* <p>
* The session manages:
* <ul>
* <li>Request/response handling with unique message IDs</li>
* <li>Notification processing</li>
* <li>Message timeout management</li>
* <li>Transport layer abstraction</li>
* </ul>
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
public class McpClientSession implements McpSession {
/**
* Logger for this class
*/
private static final Logger logger = LoggerFactory.getLogger(McpClientSession.class);
/**
* Duration to wait for request responses before timing out
*/
private final Duration requestTimeout;
/**
* Transport layer implementation for message exchange
*/
private final McpClientTransport transport;
/**
* Map of pending responses keyed by request ID
*/
private final ConcurrentHashMap<Object, MonoSink<McpSchema.JSONRPCResponse>> pendingResponses = new ConcurrentHashMap<>();
/**
* Map of request handlers keyed by method name
*/
private final ConcurrentHashMap<String, RequestHandler<?>> requestHandlers = new ConcurrentHashMap<>();
/**
* Map of notification handlers keyed by method name
*/
private final ConcurrentHashMap<String, NotificationHandler> notificationHandlers = new ConcurrentHashMap<>();
/**
* Session-specific prefix for request IDs
*/
private final String sessionPrefix = UUID.randomUUID().toString().substring(0, 8);
/**
* Atomic counter for generating unique request IDs
*/
private final AtomicLong requestCounter = new AtomicLong(0);
private final Disposable connection;
/**
* Functional interface for handling incoming JSON-RPC requests. Implementations
* should process the request parameters and return a response.
*
* @param <T> Response type
*/
@FunctionalInterface
public interface RequestHandler<T> {
/**
* Handles an incoming request with the given parameters.
*
* @param params The request parameters
* @return A Mono containing the response object
*/
Mono<T> handle(Object params);
}
/**
* Functional interface for handling incoming JSON-RPC notifications. Implementations
* should process the notification parameters without returning a response.
*/
@FunctionalInterface
public interface NotificationHandler {
/**
* Handles an incoming notification with the given parameters.
*
* @param params The notification parameters
* @return A Mono that completes when the notification is processed
*/
Mono<Void> handle(Object params);
}
/**
* Creates a new McpClientSession with the specified configuration and handlers.
*
* @param requestTimeout Duration to wait for responses
* @param transport Transport implementation for message exchange
* @param requestHandlers Map of method names to request handlers
* @param notificationHandlers Map of method names to notification handlers
*/
public McpClientSession(Duration requestTimeout, McpClientTransport transport,
Map<String, RequestHandler<?>> requestHandlers, Map<String, NotificationHandler> notificationHandlers) {
Assert.notNull(requestTimeout, "The requestTimeout can not be null");
Assert.notNull(transport, "The transport can not be null");
Assert.notNull(requestHandlers, "The requestHandlers can not be null");
Assert.notNull(notificationHandlers, "The notificationHandlers can not be null");
this.requestTimeout = requestTimeout;
this.transport = transport;
this.requestHandlers.putAll(requestHandlers);
this.notificationHandlers.putAll(notificationHandlers);
// TODO: consider mono.transformDeferredContextual where the Context contains
// the
// Observation associated with the individual message - it can be used to
// create child Observation and emit it together with the message to the
// consumer
this.connection = this.transport.connect(mono -> mono.doOnNext(message -> {
if (message instanceof McpSchema.JSONRPCResponse response) {
logger.debug("Received Response: {}", response);
var sink = pendingResponses.remove(response.id());
if (sink == null) {
logger.warn("Unexpected response for unknown id {}", response.id());
} else {
sink.success(response);
}
} else if (message instanceof McpSchema.JSONRPCRequest request) {
logger.debug("Received request: {}", request);
handleIncomingRequest(request).subscribe(response -> transport.sendMessage(response).subscribe(),
error -> {
var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
null, new McpSchema.JSONRPCResponse.JSONRPCError(
McpSchema.ErrorCodes.INTERNAL_ERROR, error.getMessage(), null));
transport.sendMessage(errorResponse).subscribe();
});
} else if (message instanceof McpSchema.JSONRPCNotification notification) {
logger.debug("Received notification: {}", notification);
handleIncomingNotification(notification).subscribe(null,
error -> logger.error("Error handling notification: {}", error.getMessage()));
}
}).doOnError(error -> {
logger.error("Error handling message: {}", error.getMessage());
close();
})).subscribe();
}
/**
* Handles an incoming JSON-RPC request by routing it to the appropriate handler.
*
* @param request The incoming JSON-RPC request
* @return A Mono containing the JSON-RPC response
*/
private Mono<McpSchema.JSONRPCResponse> handleIncomingRequest(McpSchema.JSONRPCRequest request) {
return Mono.defer(() -> {
var handler = this.requestHandlers.get(request.method());
if (handler == null) {
MethodNotFoundError error = getMethodNotFoundError(request.method());
return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND,
error.message(), error.data())));
}
return handler.handle(request.params())
.map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null))
.onErrorResume(error -> Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
null, new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
error.getMessage(), null)))); // TODO: add error message
// through the data field
});
}
record MethodNotFoundError(String method, String message, Object data) {
}
public static MethodNotFoundError getMethodNotFoundError(String method) {
switch (method) {
case McpSchema.METHOD_ROOTS_LIST:
return new MethodNotFoundError(method, "Roots not supported",
Map.of("reason", "Client does not have roots capability"));
default:
return new MethodNotFoundError(method, "Method not found: " + method, null);
}
}
/**
* Handles an incoming JSON-RPC notification by routing it to the appropriate handler.
*
* @param notification The incoming JSON-RPC notification
* @return A Mono that completes when the notification is processed
*/
private Mono<Void> handleIncomingNotification(McpSchema.JSONRPCNotification notification) {
return Mono.defer(() -> {
var handler = notificationHandlers.get(notification.method());
if (handler == null) {
logger.error("No handler registered for notification method: {}", notification.method());
return Mono.empty();
}
return handler.handle(notification.params());
});
}
/**
* Generates a unique request ID in a non-blocking way. Combines a session-specific
* prefix with an atomic counter to ensure uniqueness.
*
* @return A unique request ID string
*/
private String generateRequestId() {
return this.sessionPrefix + "-" + this.requestCounter.getAndIncrement();
}
/**
* Sends a JSON-RPC request and returns the response.
*
* @param <T> The expected response type
* @param method The method name to call
* @param requestParams The request parameters
* @param typeRef Type reference for response deserialization
* @return A Mono containing the response
*/
@Override
public <T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef) {
String requestId = this.generateRequestId();
return Mono.<McpSchema.JSONRPCResponse>create(sink -> {
this.pendingResponses.put(requestId, sink);
McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method,
requestId, requestParams);
this.transport.sendMessage(jsonrpcRequest)
// TODO: It's most efficient to create a dedicated Subscriber here
.subscribe(v -> {
}, error -> {
this.pendingResponses.remove(requestId);
sink.error(error);
});
}).timeout(this.requestTimeout).handle((jsonRpcResponse, sink) -> {
if (jsonRpcResponse.error() != null) {
sink.error(new McpError(jsonRpcResponse.error()));
} else {
if (typeRef.getType().equals(Void.class)) {
sink.complete();
} else {
sink.next(this.transport.unmarshalFrom(jsonRpcResponse.result(), typeRef));
}
}
});
}
/**
* Sends a JSON-RPC notification.
*
* @param method The method name for the notification
* @param params The notification parameters
* @return A Mono that completes when the notification is sent
*/
@Override
public Mono<Void> sendNotification(String method, Object params) {
McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
method, params);
return this.transport.sendMessage(jsonrpcNotification);
}
/**
* Closes the session gracefully, allowing pending operations to complete.
*
* @return A Mono that completes when the session is closed
*/
@Override
public Mono<Void> closeGracefully() {
pendingResponses.forEach((id, sink) -> sink.error(new Exception("Session closed")));
return Mono.defer(() -> {
this.connection.dispose();
return transport.closeGracefully();
});
}
/**
* Closes the session immediately, potentially interrupting pending operations.
*/
@Override
public void close() {
this.connection.dispose();
transport.close();
pendingResponses.forEach((id, sink) -> sink.error(new Exception("Session closed")));
pendingResponses.clear();
}
}

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.mcp.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface McpConfigMapper extends BaseMapper<McpConfig> {
}

View File

@@ -0,0 +1,11 @@
package com.xspaceagi.mcp.infra.repository;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.mcp.adapter.repository.McpConfigRepository;
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
import com.xspaceagi.mcp.infra.dao.mapper.McpConfigMapper;
import org.springframework.stereotype.Service;
@Service
public class McpConfigRepositoryImpl extends ServiceImpl<McpConfigMapper, McpConfig> implements McpConfigRepository {
}

View File

@@ -0,0 +1,29 @@
package com.xspaceagi.mcp.infra.rpc;
import com.xspaceagi.eco.market.sdk.reponse.ClientSecretResponse;
import com.xspaceagi.eco.market.sdk.request.ClientSecretRequest;
import com.xspaceagi.eco.market.sdk.service.IEcoMarketRpcService;
import com.xspaceagi.system.spec.cache.SimpleJvmHashCache;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@Service
public class MarketplaceRpcService {
@Resource
private IEcoMarketRpcService ecoMarketRpcService;
public ClientSecretResponse queryClientSecret(ClientSecretRequest request) {
Object clientSecret = SimpleJvmHashCache.getHash("client_secret", request.getTenantId().toString());
if (clientSecret != null) {
return (ClientSecretResponse) clientSecret;
}
ClientSecretResponse clientSecretResponse = ecoMarketRpcService.queryClientSecret(request);
if (clientSecretResponse != null){
SimpleJvmHashCache.putHash("client_secret", request.getTenantId().toString(), clientSecretResponse, 7200);
return clientSecretResponse;
}
return null;
}
}

View File

@@ -0,0 +1,236 @@
package com.xspaceagi.mcp.infra.rpc;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.xspaceagi.eco.market.sdk.reponse.ClientSecretResponse;
import com.xspaceagi.eco.market.sdk.request.ClientSecretRequest;
import com.xspaceagi.mcp.infra.client.HttpClientSseClientTransport;
import com.xspaceagi.mcp.infra.client.McpAsyncClient;
import com.xspaceagi.mcp.infra.client.McpAsyncClientWrapper;
import com.xspaceagi.mcp.infra.client.McpClient;
import com.xspaceagi.mcp.infra.rpc.dto.McpDeployStatusResponse;
import com.xspaceagi.mcp.infra.rpc.enums.McpPersistentTypeEnum;
import com.xspaceagi.mcp.spec.utils.UrlExtractUtil;
import com.xspaceagi.system.spec.cache.SimpleJvmHashCache;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.dto.ReqResult;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import com.xspaceagi.system.spec.utils.HttpClient;
import com.xspaceagi.system.spec.utils.MD5;
import io.modelcontextprotocol.spec.McpError;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.http.HttpRequest;
import java.nio.charset.Charset;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeoutException;
@Service
@Slf4j
public class McpDeployRpcService {
static {
System.setProperty("jdk.httpclient.keepalive.timeout", "0");
}
private static final String MCP_VERSION_KEY = "mcp-version:";
@Value("${mcp.proxy-base-url:}")
private String mcpProxyBaseUrl;
@Resource
private HttpClient httpClient;
@Resource
private MarketplaceRpcService marketplaceRpcService;
private java.net.http.HttpClient sseHttpClient = java.net.http.HttpClient.newBuilder()
.version(java.net.http.HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10)).build();
public McpDeployStatusResponse deploy(String mcpId, String serverConfig, McpPersistentTypeEnum persistentType) {
String statusApi = mcpProxyBaseUrl + "/mcp/sse/check_status";
String mcpKey = getDeployMcpKey(mcpId, serverConfig);
JSONObject params = new JSONObject();
params.put("mcpId", mcpKey);
params.put("mcpType", persistentType.name());
params.put("mcpJsonConfig", JSON.parseObject(serverConfig).toJSONString());
String content = httpClient.post(statusApi, params.toJSONString(), new HashMap<>());
log.debug("Mcp deploy result: {}", content);
ReqResult result = JSON.parseObject(content, ReqResult.class);
if (!result.isSuccess()) {
log.warn("Mcp deploy failed: {}", result.getMessage());
throw BizException.of(ErrorCodeEnum.ERROR_REQUEST, BizExceptionCodeEnum.remoteServiceMessage,
result.getMessage() != null ? result.getMessage() : "");
}
McpDeployStatusResponse mcpDeployStatusResponse = ((JSONObject) result.getData()).toJavaObject(McpDeployStatusResponse.class);
if (mcpDeployStatusResponse == null) {
throw BizException.of(ErrorCodeEnum.SYS_ERROR, BizExceptionCodeEnum.mcpDeployResponseNull);
}
return mcpDeployStatusResponse;
}
public Mono<McpAsyncClientWrapper> getMcpAsyncClient(String mcpId, String conversationId, String serverConfig) {
Object val = SimpleJvmHashCache.removeHash(getPoolKey(mcpId), conversationId);
if (val != null) {
return Mono.just((McpAsyncClientWrapper) val);
}
Object mcpKey = getMcpKey(mcpId, conversationId);
HttpRequest.Builder builder = HttpRequest.newBuilder()
.header("content-type", "application/json")
.header("x-mcp-json", Base64.encodeBase64String(serverConfig.getBytes(Charset.forName("UTF-8"))))//"{\"mcpServers\":{\"@upstash/context7-mcp\":{\"command\":\"npx1\",\"args\":[\"-y\",\"@mcp_hub_org/cli@latest\",\"run\",\"@upstash/context7-mcp\"]}}}")//
.header("x-mcp-type", "OneShot");
HttpClientSseClientTransport.Builder httpClientSseClientTransportBuilder = HttpClientSseClientTransport.builder(mcpProxyBaseUrl)
.sseEndpoint("/mcp/sse/proxy/" + mcpKey + "/sse")
.httpClient(sseHttpClient)
.requestBuilder(builder);
return buildClientWrapper(httpClientSseClientTransportBuilder, mcpId, serverConfig);
}
public Mono<McpAsyncClientWrapper> getMcpAsyncClientForSSE(String mcpId, String conversationId, String serverConfig) {
//从serverConfig中解析出sse地址
List<String> list = UrlExtractUtil.extractUrls(serverConfig);
if (list.size() == 0) {
return Mono.error(new IllegalArgumentException("Invalid serverConfig"));
}
Object val = SimpleJvmHashCache.removeHash(getPoolKey(mcpId), conversationId);
if (val != null) {
return Mono.just((McpAsyncClientWrapper) val);
}
String baseUrl;
String endpoint;
try {
URL urlObj = new URL(list.get(0));
// 1. 获取根地址(协议 + 域名 + 端口)
String protocol = urlObj.getProtocol();
String host = urlObj.getHost();
int port = urlObj.getPort();
baseUrl = protocol + "://" + host;
if (port != -1) {
baseUrl += ":" + port;
}
// 2. 获取带参数的URI路径
String path = urlObj.getPath();
String query = urlObj.getQuery();
String fullUri = path;
if (query != null) {
fullUri += "?" + query;
}
endpoint = fullUri;
try {
ClientSecretResponse clientSecretResponse = marketplaceRpcService.queryClientSecret(new ClientSecretRequest(RequestContext.get().getTenantId()));
if (clientSecretResponse != null) {
endpoint = endpoint.replace("TENANT_SECRET", clientSecretResponse.getClientSecret());
}
} catch (Exception e) {
// ignore
log.warn("queryClientSecret error", e);
}
} catch (MalformedURLException e) {
throw new IllegalArgumentException(e);
}
HttpClientSseClientTransport.Builder builder = HttpClientSseClientTransport.builder(baseUrl);
builder.sseEndpoint(endpoint);
builder.httpClient(sseHttpClient);
try {
Map<String, String> headers = UrlExtractUtil.extractHeaders(serverConfig);
if (headers != null && headers.size() > 0) {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
requestBuilder.header("Content-Type", "application/json");
headers.forEach((k, v) -> requestBuilder.header(k, v));
builder.requestBuilder(requestBuilder);
}
} catch (Exception e) {
// ignore
log.warn("extractHeaders error", e);
}
return buildClientWrapper(builder, mcpId, serverConfig);
}
private Mono<McpAsyncClientWrapper> buildClientWrapper(HttpClientSseClientTransport.Builder builder, String mcpId, String serverConfig) {
return Mono.create(emitter -> {
HttpClientSseClientTransport httpClientSseClientTransport = builder.build();
McpAsyncClient client = McpClient.async(httpClientSseClientTransport).requestTimeout(Duration.ofMinutes(30)).build();
client.initialize().timeout(Duration.ofSeconds(20))
.onErrorResume(throwable -> {
if (throwable instanceof TimeoutException) {
log.warn("MCP {} initialize timeout", mcpId);
return Mono.error(new McpError("MCP initialize timeout"));
}
return Mono.error(new McpError(throwable.getMessage()));
}).doOnError((throwable) -> {
log.warn("MCP {} initialize failed", mcpId);
httpClientSseClientTransport.close();
client.close();
if (throwable instanceof McpError) {
emitter.error(throwable);
return;
}
emitter.error(new McpError("MCP initialize failed"));
}).doOnSuccess(result -> {
log.info("MCP {} initialized: {}", mcpId, serverConfig);
McpAsyncClientWrapper clientWrapper = McpAsyncClientWrapper.builder().httpClientSseClientTransport(httpClientSseClientTransport)
.client(client).build();
emitter.success(clientWrapper);
}).subscribe();
});
}
private String getMcpKey(String mcpId, String conversationId) {
return mcpId + "-" + conversationId;
}
public void returnMcpClient(String mcpId, String conversationId, McpAsyncClientWrapper mcpAsyncClientWrapper) {
log.debug("returnMcpClient: {} {} {}", mcpId, conversationId, mcpAsyncClientWrapper);
if (conversationId == null) {
mcpAsyncClientWrapper.close();
return;
}
Object val = SimpleJvmHashCache.getHash(getPoolKey(mcpId), conversationId);
if (val != mcpAsyncClientWrapper) {
try {
((McpAsyncClient) val).close();
} catch (Exception e) {
// ignore
}
}
//缓存30秒没有再使用就关闭
SimpleJvmHashCache.putHash(getPoolKey(mcpId), conversationId, mcpAsyncClientWrapper, 30, (mcpClient) -> {
mcpAsyncClientWrapper.close();
});
}
public void closeMcpClient(String mcpId, String conversationId, McpAsyncClientWrapper mcpAsyncClientWrapper) {
log.debug("closeMcpClient: {} {} {}", mcpId, conversationId, mcpAsyncClientWrapper);
if (mcpAsyncClientWrapper == null) {
mcpAsyncClientWrapper = (McpAsyncClientWrapper) SimpleJvmHashCache.getHash(getPoolKey(mcpId), conversationId);
}
SimpleJvmHashCache.removeHash(getPoolKey(mcpId), conversationId);
if (mcpAsyncClientWrapper != null) {
mcpAsyncClientWrapper.close();
String deleteApi = mcpProxyBaseUrl + "/mcp/config/delete/{mcp_id}";
httpClient.delete(deleteApi.replace("{mcp_id}", getMcpKey(mcpId, conversationId)), new HashMap<>());
}
}
private String getDeployMcpKey(String mcpId, String serverConfig) {
return getMcpKey(mcpId, MD5.MD5Encode(serverConfig));
}
private String getPoolKey(String mcpId) {
return MCP_VERSION_KEY + mcpId;
}
}

View File

@@ -0,0 +1,12 @@
package com.xspaceagi.mcp.infra.rpc.dto;
import com.xspaceagi.mcp.infra.rpc.enums.McpDeployStatusEnum;
import lombok.Data;
@Data
public class McpDeployStatusResponse {
private Boolean ready;
private McpDeployStatusEnum status;
private String message;
}

View File

@@ -0,0 +1,5 @@
package com.xspaceagi.mcp.infra.rpc.enums;
public enum McpDeployStatusEnum {
Ready, Pending, Error
}

View File

@@ -0,0 +1,5 @@
package com.xspaceagi.mcp.infra.rpc.enums;
public enum McpPersistentTypeEnum {
Persistent, OneShot
}

View File

@@ -0,0 +1,802 @@
package com.xspaceagi.mcp.infra.server;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xspaceagi.mcp.infra.client.McpAsyncClientWrapper;
import io.modelcontextprotocol.spec.McpClientSession;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.BiFunction;
import static io.modelcontextprotocol.spec.McpSchema.*;
/**
* The Model Context Protocol (MCP) server implementation that provides asynchronous
* communication using Project Reactor's Mono and Flux types.
*
* <p>
* This server implements the MCP specification, enabling AI models to expose tools,
* resources, and prompts through a standardized interface. Key features include:
* <ul>
* <li>Asynchronous communication using reactive programming patterns
* <li>Dynamic tool registration and management
* <li>Resource handling with URI-based addressing
* <li>Prompt template management
* <li>Real-time client notifications for state changes
* <li>Structured logging with configurable severity levels
* <li>Support for client-side AI model sampling
* </ul>
*
* <p>
* The server follows a lifecycle:
* <ol>
* <li>Initialization - Accepts client connections and negotiates capabilities
* <li>Normal Operation - Handles client requests and sends notifications
* <li>Graceful Shutdown - Ensures clean connection termination
* </ol>
*
* <p>
* This implementation uses Project Reactor for non-blocking operations, making it
* suitable for high-throughput scenarios and reactive applications. All operations return
* Mono or Flux types that can be composed into reactive pipelines.
*
* <p>
* The server supports runtime modification of its capabilities through methods like
* {@link #addTool}, {@link #addResource}, and {@link #addPrompt}, automatically notifying
* connected clients of changes when configured to do so.
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
* @see McpServer
* @see McpSchema
* @see McpClientSession
*/
public class McpAsyncServer {
private static final Logger logger = LoggerFactory.getLogger(McpAsyncServer.class);
private final McpAsyncServer delegate;
protected Mono<McpAsyncClientWrapper> mcpAsyncClientWrapperMono;
protected McpAsyncClientWrapper mcpAsyncClientWrapper;
McpAsyncServer() {
this.delegate = null;
}
/**
* Create a new McpAsyncServer with the given transport provider and capabilities.
*
* @param mcpTransportProvider The transport layer implementation for MCP
* communication.
* @param features The MCP server supported features.
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
*/
McpAsyncServer(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
McpServerFeatures.Async features, boolean isProxy) {
this.delegate = new AsyncServerImpl(mcpTransportProvider, objectMapper, features, isProxy);
}
public void setMcpProxyAsyncClientMono(Mono<McpAsyncClientWrapper> mcpAsyncClientWrapperMono) {
this.delegate.mcpAsyncClientWrapperMono = mcpAsyncClientWrapperMono;
}
/**
* Get the server capabilities that define the supported features and functionality.
*
* @return The server capabilities
*/
public McpSchema.ServerCapabilities getServerCapabilities() {
return this.delegate.getServerCapabilities();
}
/**
* Get the server implementation information.
*
* @return The server implementation details
*/
public McpSchema.Implementation getServerInfo() {
return this.delegate.getServerInfo();
}
/**
* Gracefully closes the server, allowing any in-progress operations to complete.
*
* @return A Mono that completes when the server has been closed
*/
public Mono<Void> closeGracefully() {
return this.delegate.closeGracefully();
}
/**
* Close the server immediately.
*/
public void close() {
this.delegate.close();
}
// ---------------------------------------
// Tool Management
// ---------------------------------------
/**
* Add a new tool specification at runtime.
*
* @param toolSpecification The tool specification to add
* @return Mono that completes when clients have been notified of the change
*/
public Mono<Void> addTool(McpServerFeatures.AsyncToolSpecification toolSpecification) {
return this.delegate.addTool(toolSpecification);
}
/**
* Remove a tool handler at runtime.
*
* @param toolName The name of the tool handler to remove
* @return Mono that completes when clients have been notified of the change
*/
public Mono<Void> removeTool(String toolName) {
return this.delegate.removeTool(toolName);
}
/**
* Notifies clients that the list of available tools has changed.
*
* @return A Mono that completes when all clients have been notified
*/
public Mono<Void> notifyToolsListChanged() {
return this.delegate.notifyToolsListChanged();
}
// ---------------------------------------
// Resource Management
// ---------------------------------------
/**
* Add a new resource handler at runtime.
*
* @param resourceHandler The resource handler to add
* @return Mono that completes when clients have been notified of the change
*/
public Mono<Void> addResource(McpServerFeatures.AsyncResourceSpecification resourceHandler) {
return this.delegate.addResource(resourceHandler);
}
/**
* Remove a resource handler at runtime.
*
* @param resourceUri The URI of the resource handler to remove
* @return Mono that completes when clients have been notified of the change
*/
public Mono<Void> removeResource(String resourceUri) {
return this.delegate.removeResource(resourceUri);
}
/**
* Notifies clients that the list of available resources has changed.
*
* @return A Mono that completes when all clients have been notified
*/
public Mono<Void> notifyResourcesListChanged() {
return this.delegate.notifyResourcesListChanged();
}
// ---------------------------------------
// Prompt Management
// ---------------------------------------
/**
* Add a new prompt handler at runtime.
*
* @param promptSpecification The prompt handler to add
* @return Mono that completes when clients have been notified of the change
*/
public Mono<Void> addPrompt(McpServerFeatures.AsyncPromptSpecification promptSpecification) {
return this.delegate.addPrompt(promptSpecification);
}
/**
* Remove a prompt handler at runtime.
*
* @param promptName The name of the prompt handler to remove
* @return Mono that completes when clients have been notified of the change
*/
public Mono<Void> removePrompt(String promptName) {
return this.delegate.removePrompt(promptName);
}
/**
* Notifies clients that the list of available prompts has changed.
*
* @return A Mono that completes when all clients have been notified
*/
public Mono<Void> notifyPromptsListChanged() {
return this.delegate.notifyPromptsListChanged();
}
// ---------------------------------------
// Logging Management
// ---------------------------------------
/**
* This implementation would, incorrectly, broadcast the logging message to all
* connected clients, using a single minLoggingLevel for all of them. Similar to the
* sampling and roots, the logging level should be set per client session and use the
* ServerExchange to send the logging message to the right client.
*
* @param loggingMessageNotification The logging message to send
* @return A Mono that completes when the notification has been sent
* @deprecated Use
* {@link McpAsyncServerExchange#loggingNotification(LoggingMessageNotification)}
* instead.
*/
@Deprecated
public Mono<Void> loggingNotification(LoggingMessageNotification loggingMessageNotification) {
return this.delegate.loggingNotification(loggingMessageNotification);
}
public McpServerSession.Factory getSessionFactory() {
return this.delegate.getSessionFactory();
}
// ---------------------------------------
// Sampling
// ---------------------------------------
/**
* This method is package-private and used for test only. Should not be called by user
* code.
*
* @param protocolVersions the Client supported protocol versions.
*/
void setProtocolVersions(List<String> protocolVersions) {
this.delegate.setProtocolVersions(protocolVersions);
}
private static class AsyncServerImpl extends McpAsyncServer {
private final McpServerTransportProvider mcpTransportProvider;
private McpServerSession.Factory sessionFactory;
private final ObjectMapper objectMapper;
private McpSchema.ServerCapabilities serverCapabilities;
private McpSchema.Implementation serverInfo;
private String instructions;
private final CopyOnWriteArrayList<McpServerFeatures.AsyncToolSpecification> tools = new CopyOnWriteArrayList<>();
private final CopyOnWriteArrayList<McpSchema.ResourceTemplate> resourceTemplates = new CopyOnWriteArrayList<>();
private final ConcurrentHashMap<String, McpServerFeatures.AsyncResourceSpecification> resources = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, McpServerFeatures.AsyncPromptSpecification> prompts = new ConcurrentHashMap<>();
// FIXME: this field is deprecated and should be remvoed together with the
// broadcasting loggingNotification.
private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG;
private List<String> protocolVersions = List.of(LATEST_PROTOCOL_VERSION);
AsyncServerImpl(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
McpServerFeatures.Async features, boolean isProxy) {
this.mcpTransportProvider = mcpTransportProvider;
this.objectMapper = objectMapper;
this.serverInfo = features.serverInfo();
this.serverCapabilities = features.serverCapabilities();
this.instructions = features.instructions();
this.tools.addAll(features.tools());
this.resources.putAll(features.resources());
this.resourceTemplates.addAll(features.resourceTemplates());
this.prompts.putAll(features.prompts());
Map<String, McpServerSession.RequestHandler<?>> 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) -> {
//代理执行
if (mcpAsyncClientWrapper != null) {
return mcpAsyncClientWrapper.getClient().ping();
}
return Mono.just(Map.of());
});
// Add tools API handlers if the tool capability is enabled
if (this.serverCapabilities.tools() != null || isProxy) {
requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler());
requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler());
}
// Add resources API handlers if provided
if (this.serverCapabilities.resources() != null || isProxy) {
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.prompts() != null || isProxy) {
requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler());
requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler());
}
// Add logging API handlers if the logging capability is enabled
if (this.serverCapabilities.logging() != null || isProxy) {
requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler());
}
Map<String, McpServerSession.NotificationHandler> notificationHandlers = new HashMap<>();
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (exchange, params) -> Mono.empty());
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers = features
.rootsChangeConsumers();
if (Utils.isEmpty(rootsChangeConsumers)) {
rootsChangeConsumers = List.of((exchange,
roots) -> Mono.fromRunnable(() -> logger.warn(
"Roots list changed notification, but no consumers provided. Roots list changed: {}",
roots)));
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED,
asyncRootsListChangedNotificationHandler(rootsChangeConsumers));
sessionFactory = transport -> new McpServerSession(UUID.randomUUID().toString(), transport,
this::asyncInitializeRequestHandler, Mono::empty, requestHandlers, notificationHandlers);
}
// ---------------------------------------
// Lifecycle Management
// ---------------------------------------
private Mono<McpSchema.InitializeResult> asyncInitializeRequestHandler(
McpSchema.InitializeRequest initializeRequest) {
//代理执行
if (mcpAsyncClientWrapperMono != null) {
return Mono.create(sink ->
mcpAsyncClientWrapperMono.doOnSuccess(mcpProxyAsyncClientWrapper -> {
this.mcpAsyncClientWrapper = mcpProxyAsyncClientWrapper;
this.serverCapabilities = mcpProxyAsyncClientWrapper.getClient().getServerCapabilities();
this.serverInfo = mcpProxyAsyncClientWrapper.getClient().getServerInfo();
McpSchema.InitializeResult initializeResult0 = new McpSchema.InitializeResult(LATEST_PROTOCOL_VERSION, serverCapabilities, serverInfo, instructions);
sink.success(initializeResult0);
}).doOnError(sink::error).subscribe());
}
return Mono.defer(() -> {
logger.info("Client initialize request - Protocol: {}, Capabilities: {}, Info: {}",
initializeRequest.protocolVersion(), initializeRequest.capabilities(),
initializeRequest.clientInfo());
// The server MUST respond with the highest protocol version it supports
// if
// it does not support the requested (e.g. Client) version.
String serverProtocolVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
if (this.protocolVersions.contains(initializeRequest.protocolVersion())) {
// If the server supports the requested protocol version, it MUST
// respond
// with the same version.
serverProtocolVersion = initializeRequest.protocolVersion();
} else {
logger.warn(
"Client requested unsupported protocol version: {}, so the server will sugggest the {} version instead",
initializeRequest.protocolVersion(), serverProtocolVersion);
}
return Mono.just(new McpSchema.InitializeResult(serverProtocolVersion, this.serverCapabilities,
this.serverInfo, this.instructions));
});
}
public McpSchema.ServerCapabilities getServerCapabilities() {
return this.serverCapabilities;
}
public McpSchema.Implementation getServerInfo() {
return this.serverInfo;
}
@Override
public Mono<Void> closeGracefully() {
if (this.mcpAsyncClientWrapper != null) {
return this.mcpAsyncClientWrapper.closeGracefully();
}
return Mono.empty();
}
@Override
public void close() {
if (this.mcpAsyncClientWrapper != null) {
this.mcpAsyncClientWrapper.close();
}
}
private McpServerSession.NotificationHandler asyncRootsListChangedNotificationHandler(
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers) {
return (exchange, params) -> exchange.listRoots()
.flatMap(listRootsResult -> Flux.fromIterable(rootsChangeConsumers)
.flatMap(consumer -> consumer.apply(exchange, listRootsResult.roots()))
.onErrorResume(error -> {
logger.error("Error handling roots list change notification", error);
return Mono.empty();
})
.then());
}
// ---------------------------------------
// Tool Management
// ---------------------------------------
@Override
public Mono<Void> addTool(McpServerFeatures.AsyncToolSpecification toolSpecification) {
if (toolSpecification == null) {
return Mono.error(new McpError("Tool specification must not be null"));
}
if (toolSpecification.tool() == null) {
return Mono.error(new McpError("Tool must not be null"));
}
if (toolSpecification.call() == null) {
return Mono.error(new McpError("Tool call handler must not be null"));
}
if (this.serverCapabilities.tools() == null) {
return Mono.error(new McpError("Server must be configured with tool capabilities"));
}
return Mono.defer(() -> {
// Check for duplicate tool names
if (this.tools.stream().anyMatch(th -> th.tool().name().equals(toolSpecification.tool().name()))) {
return Mono
.error(new McpError("Tool with name '" + toolSpecification.tool().name() + "' already exists"));
}
this.tools.add(toolSpecification);
logger.debug("Added tool handler: {}", toolSpecification.tool().name());
if (this.serverCapabilities.tools().listChanged()) {
return notifyToolsListChanged();
}
return Mono.empty();
});
}
@Override
public Mono<Void> removeTool(String toolName) {
if (toolName == null) {
return Mono.error(new McpError("Tool name must not be null"));
}
if (this.serverCapabilities.tools() == null) {
return Mono.error(new McpError("Server must be configured with tool capabilities"));
}
return Mono.defer(() -> {
boolean removed = this.tools
.removeIf(toolSpecification -> toolSpecification.tool().name().equals(toolName));
if (removed) {
logger.debug("Removed tool handler: {}", toolName);
if (this.serverCapabilities.tools().listChanged()) {
return notifyToolsListChanged();
}
return Mono.empty();
}
return Mono.error(new McpError("Tool with name '" + toolName + "' not found"));
});
}
@Override
public Mono<Void> notifyToolsListChanged() {
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
}
private McpServerSession.RequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
return (exchange, params) -> {
//代理执行
if (mcpAsyncClientWrapper != null) {
return mcpAsyncClientWrapper.getClient().listTools().timeout(Duration.ofSeconds(20)).onErrorResume(error -> Mono.error(new McpError("MCP tools/list timeout")));
}
List<Tool> tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
return Mono.just(new McpSchema.ListToolsResult(tools, null));
};
}
private McpServerSession.RequestHandler<CallToolResult> toolsCallRequestHandler() {
return (exchange, params) -> {
McpSchema.CallToolRequest callToolRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.CallToolRequest>() {
});
//代理执行
if (mcpAsyncClientWrapper != null) {
return mcpAsyncClientWrapper.getClient().callTool(callToolRequest);
}
Optional<McpServerFeatures.AsyncToolSpecification> toolSpecification = this.tools.stream()
.filter(tr -> callToolRequest.name().equals(tr.tool().name()))
.findAny();
if (toolSpecification.isEmpty()) {
return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
}
return toolSpecification.map(tool -> tool.call().apply(exchange, callToolRequest.arguments()))
.orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name())));
};
}
// ---------------------------------------
// Resource Management
// ---------------------------------------
@Override
public Mono<Void> addResource(McpServerFeatures.AsyncResourceSpecification resourceSpecification) {
if (resourceSpecification == null || resourceSpecification.resource() == null) {
return Mono.error(new McpError("Resource must not be null"));
}
if (this.serverCapabilities.resources() == null) {
return Mono.error(new McpError("Server must be configured with resource capabilities"));
}
return Mono.defer(() -> {
if (this.resources.putIfAbsent(resourceSpecification.resource().uri(), resourceSpecification) != null) {
return Mono.error(new McpError(
"Resource with URI '" + resourceSpecification.resource().uri() + "' already exists"));
}
logger.debug("Added resource handler: {}", resourceSpecification.resource().uri());
if (this.serverCapabilities.resources().listChanged()) {
return notifyResourcesListChanged();
}
return Mono.empty();
});
}
@Override
public Mono<Void> removeResource(String resourceUri) {
if (resourceUri == null) {
return Mono.error(new McpError("Resource URI must not be null"));
}
if (this.serverCapabilities.resources() == null) {
return Mono.error(new McpError("Server must be configured with resource capabilities"));
}
return Mono.defer(() -> {
McpServerFeatures.AsyncResourceSpecification removed = this.resources.remove(resourceUri);
if (removed != null) {
logger.debug("Removed resource handler: {}", resourceUri);
if (this.serverCapabilities.resources().listChanged()) {
return notifyResourcesListChanged();
}
return Mono.empty();
}
return Mono.error(new McpError("Resource with URI '" + resourceUri + "' not found"));
});
}
@Override
public Mono<Void> notifyResourcesListChanged() {
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null);
}
private McpServerSession.RequestHandler<McpSchema.ListResourcesResult> resourcesListRequestHandler() {
return (exchange, params) -> {
//代理执行
if (mcpAsyncClientWrapper != null) {
return mcpAsyncClientWrapper.getClient().listResources();
}
var resourceList = this.resources.values()
.stream()
.map(McpServerFeatures.AsyncResourceSpecification::resource)
.toList();
return Mono.just(new McpSchema.ListResourcesResult(resourceList, null));
};
}
private McpServerSession.RequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplateListRequestHandler() {
return (exchange, params) -> {
//代理执行
if (mcpAsyncClientWrapper != null) {
return mcpAsyncClientWrapper.getClient().listResourceTemplates();
}
return Mono.just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null));
};
}
private McpServerSession.RequestHandler<McpSchema.ReadResourceResult> resourcesReadRequestHandler() {
return (exchange, params) -> {
McpSchema.ReadResourceRequest resourceRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.ReadResourceRequest>() {
});
//代理执行
if (mcpAsyncClientWrapper != null) {
return mcpAsyncClientWrapper.getClient().readResource(resourceRequest);
}
var resourceUri = resourceRequest.uri();
McpServerFeatures.AsyncResourceSpecification specification = this.resources.get(resourceUri);
if (specification != null) {
return specification.readHandler().apply(exchange, resourceRequest);
}
return Mono.error(new McpError("Resource not found: " + resourceUri));
};
}
// ---------------------------------------
// Prompt Management
// ---------------------------------------
@Override
public Mono<Void> addPrompt(McpServerFeatures.AsyncPromptSpecification promptSpecification) {
if (promptSpecification == null) {
return Mono.error(new McpError("Prompt specification must not be null"));
}
if (this.serverCapabilities.prompts() == null) {
return Mono.error(new McpError("Server must be configured with prompt capabilities"));
}
return Mono.defer(() -> {
McpServerFeatures.AsyncPromptSpecification specification = this.prompts
.putIfAbsent(promptSpecification.prompt().name(), promptSpecification);
if (specification != null) {
return Mono.error(new McpError(
"Prompt with name '" + promptSpecification.prompt().name() + "' already exists"));
}
logger.debug("Added prompt handler: {}", promptSpecification.prompt().name());
// Servers that declared the listChanged capability SHOULD send a
// notification,
// when the list of available prompts changes
if (this.serverCapabilities.prompts().listChanged()) {
return notifyPromptsListChanged();
}
return Mono.empty();
});
}
@Override
public Mono<Void> removePrompt(String promptName) {
if (promptName == null) {
return Mono.error(new McpError("Prompt name must not be null"));
}
if (this.serverCapabilities.prompts() == null) {
return Mono.error(new McpError("Server must be configured with prompt capabilities"));
}
return Mono.defer(() -> {
McpServerFeatures.AsyncPromptSpecification removed = this.prompts.remove(promptName);
if (removed != null) {
logger.debug("Removed prompt handler: {}", promptName);
// Servers that declared the listChanged capability SHOULD send a
// notification, when the list of available prompts changes
if (this.serverCapabilities.prompts().listChanged()) {
return this.notifyPromptsListChanged();
}
return Mono.empty();
}
return Mono.error(new McpError("Prompt with name '" + promptName + "' not found"));
});
}
@Override
public Mono<Void> notifyPromptsListChanged() {
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
}
private McpServerSession.RequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler() {
return (exchange, params) -> {
// TODO: Implement pagination
// McpSchema.PaginatedRequest request = objectMapper.convertValue(params,
// new TypeReference<McpSchema.PaginatedRequest>() {
// });
//代理执行
if (mcpAsyncClientWrapper != null) {
return mcpAsyncClientWrapper.getClient().listPrompts();
}
var promptList = this.prompts.values()
.stream()
.map(McpServerFeatures.AsyncPromptSpecification::prompt)
.toList();
return Mono.just(new McpSchema.ListPromptsResult(promptList, null));
};
}
private McpServerSession.RequestHandler<McpSchema.GetPromptResult> promptsGetRequestHandler() {
return (exchange, params) -> {
McpSchema.GetPromptRequest promptRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.GetPromptRequest>() {
});
//代理执行
if (mcpAsyncClientWrapper != null) {
return mcpAsyncClientWrapper.getClient().getPrompt(promptRequest);
}
// Implement prompt retrieval logic here
McpServerFeatures.AsyncPromptSpecification specification = this.prompts.get(promptRequest.name());
if (specification == null) {
return Mono.error(new McpError("Prompt not found: " + promptRequest.name()));
}
return specification.promptHandler().apply(exchange, promptRequest);
};
}
// ---------------------------------------
// Logging Management
// ---------------------------------------
@Override
public Mono<Void> loggingNotification(LoggingMessageNotification loggingMessageNotification) {
if (loggingMessageNotification == null) {
return Mono.error(new McpError("Logging message must not be null"));
}
if (loggingMessageNotification.level().level() < minLoggingLevel.level()) {
return Mono.empty();
}
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_MESSAGE,
loggingMessageNotification);
}
private McpServerSession.RequestHandler<Object> setLoggerRequestHandler() {
return (exchange, params) -> {
return Mono.defer(() -> {
SetLevelRequest newMinLoggingLevel = objectMapper.convertValue(params,
new TypeReference<SetLevelRequest>() {
});
exchange.setMinLoggingLevel(newMinLoggingLevel.level());
// FIXME: this field is deprecated and should be removed together
// with the broadcasting loggingNotification.
this.minLoggingLevel = newMinLoggingLevel.level();
return Mono.just(Map.of());
});
};
}
// ---------------------------------------
// Sampling
// ---------------------------------------
@Override
void setProtocolVersions(List<String> protocolVersions) {
this.protocolVersions = protocolVersions;
}
@Override
public McpServerSession.Factory getSessionFactory() {
return sessionFactory;
}
}
}

View File

@@ -0,0 +1,155 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.xspaceagi.mcp.infra.server;
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Mono;
/**
* Represents an asynchronous exchange with a Model Context Protocol (MCP) client. The
* exchange provides methods to interact with the client and query its capabilities.
*
* @author Dariusz Jędrzejczyk
* @author Christian Tzolov
*/
public class McpAsyncServerExchange {
private final McpServerSession session;
private final McpSchema.ClientCapabilities clientCapabilities;
private final McpSchema.Implementation clientInfo;
private volatile LoggingLevel minLoggingLevel = LoggingLevel.INFO;
private static final TypeReference<McpSchema.CreateMessageResult> CREATE_MESSAGE_RESULT_TYPE_REF = new TypeReference<>() {
};
private static final TypeReference<McpSchema.ListRootsResult> LIST_ROOTS_RESULT_TYPE_REF = new TypeReference<>() {
};
/**
* Create a new asynchronous exchange with the client.
*
* @param session The server session representing a 1-1 interaction.
* @param clientCapabilities The client capabilities that define the supported
* features and functionality.
* @param clientInfo The client implementation information.
*/
public McpAsyncServerExchange(McpServerSession session, McpSchema.ClientCapabilities clientCapabilities,
McpSchema.Implementation clientInfo) {
this.session = session;
this.clientCapabilities = clientCapabilities;
this.clientInfo = clientInfo;
}
/**
* Get the client capabilities that define the supported features and functionality.
*
* @return The client capabilities
*/
public McpSchema.ClientCapabilities getClientCapabilities() {
return this.clientCapabilities;
}
/**
* Get the client implementation information.
*
* @return The client implementation details
*/
public McpSchema.Implementation getClientInfo() {
return this.clientInfo;
}
/**
* Create a new message using the sampling capabilities of the client. The Model
* Context Protocol (MCP) provides a standardized way for servers to request LLM
* sampling (“completions” or “generations”) from language models via clients. This
* flow allows clients to maintain control over model access, selection, and
* permissions while enabling servers to leverage AI capabilities—with no server API
* keys necessary. Servers can request text or image-based interactions and optionally
* include context from MCP servers in their prompts.
*
* @param createMessageRequest The request to create a new message
* @return A Mono that completes when the message has been created
* @see McpSchema.CreateMessageRequest
* @see McpSchema.CreateMessageResult
* @see <a href=
* "https://spec.modelcontextprotocol.io/specification/client/sampling/">Sampling
* Specification</a>
*/
public Mono<McpSchema.CreateMessageResult> createMessage(McpSchema.CreateMessageRequest createMessageRequest) {
if (this.clientCapabilities == null) {
return Mono.error(new McpError("Client must be initialized. Call the initialize method first!"));
}
if (this.clientCapabilities.sampling() == null) {
return Mono.error(new McpError("Client must be configured with sampling capabilities"));
}
return this.session.sendRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, createMessageRequest,
CREATE_MESSAGE_RESULT_TYPE_REF);
}
/**
* Retrieves the list of all roots provided by the client.
*
* @return A Mono that emits the list of roots result.
*/
public Mono<McpSchema.ListRootsResult> listRoots() {
return this.listRoots(null);
}
/**
* Retrieves a paginated list of roots provided by the client.
*
* @param cursor Optional pagination cursor from a previous list request
* @return A Mono that emits the list of roots result containing
*/
public Mono<McpSchema.ListRootsResult> listRoots(String cursor) {
return this.session.sendRequest(McpSchema.METHOD_ROOTS_LIST, new McpSchema.PaginatedRequest(cursor),
LIST_ROOTS_RESULT_TYPE_REF);
}
/**
* Send a logging message notification to all connected clients. Messages below the
* current minimum logging level will be filtered out.
*
* @param loggingMessageNotification The logging message to send
* @return A Mono that completes when the notification has been sent
*/
public Mono<Void> loggingNotification(LoggingMessageNotification loggingMessageNotification) {
if (loggingMessageNotification == null) {
return Mono.error(new McpError("Logging message must not be null"));
}
return Mono.defer(() -> {
if (this.isNotificationForLevelAllowed(loggingMessageNotification.level())) {
return this.session.sendNotification(McpSchema.METHOD_NOTIFICATION_MESSAGE, loggingMessageNotification);
}
return Mono.empty();
});
}
/**
* Set the minimum logging level for the client. Messages below this level will be
* filtered out.
*
* @param minLoggingLevel The minimum logging level
*/
void setMinLoggingLevel(LoggingLevel minLoggingLevel) {
Assert.notNull(minLoggingLevel, "minLoggingLevel must not be null");
this.minLoggingLevel = minLoggingLevel;
}
private boolean isNotificationForLevelAllowed(LoggingLevel loggingLevel) {
return loggingLevel.level() >= this.minLoggingLevel.level();
}
}

View File

@@ -0,0 +1,434 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.xspaceagi.mcp.infra.server;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.Utils;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
/**
* MCP server features specification that a particular server can choose to support.
*
* @author Dariusz Jędrzejczyk
*/
public class McpServerFeatures {
/**
* Asynchronous server features specification.
*
* @param serverInfo The server implementation details
* @param serverCapabilities The server capabilities
* @param tools The list of tool specifications
* @param resources The map of resource specifications
* @param resourceTemplates The list of resource templates
* @param prompts The map of prompt specifications
* @param rootsChangeConsumers The list of consumers that will be notified when the
* roots list changes
* @param instructions The server instructions text
*/
record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List<McpServerFeatures.AsyncToolSpecification> tools, Map<String, AsyncResourceSpecification> resources,
List<McpSchema.ResourceTemplate> resourceTemplates,
Map<String, McpServerFeatures.AsyncPromptSpecification> prompts,
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers,
String instructions) {
/**
* Create an instance and validate the arguments.
* @param serverInfo The server implementation details
* @param serverCapabilities The server capabilities
* @param tools The list of tool specifications
* @param resources The map of resource specifications
* @param resourceTemplates The list of resource templates
* @param prompts The map of prompt specifications
* @param rootsChangeConsumers The list of consumers that will be notified when
* the roots list changes
* @param instructions The server instructions text
*/
Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List<McpServerFeatures.AsyncToolSpecification> tools, Map<String, AsyncResourceSpecification> resources,
List<McpSchema.ResourceTemplate> resourceTemplates,
Map<String, McpServerFeatures.AsyncPromptSpecification> prompts,
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers,
String instructions) {
Assert.notNull(serverInfo, "Server info must not be null");
this.serverInfo = serverInfo;
this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities
: new McpSchema.ServerCapabilities(null, // experimental
new McpSchema.ServerCapabilities.LoggingCapabilities(), // Enable
// logging
// by
// default
!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 : List.of();
this.resources = (resources != null) ? resources : Map.of();
this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : List.of();
this.prompts = (prompts != null) ? prompts : Map.of();
this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : List.of();
this.instructions = instructions;
}
/**
* Convert a synchronous specification into an asynchronous one and provide
* blocking code offloading to prevent accidental blocking of the non-blocking
* transport.
* @param syncSpec a potentially blocking, synchronous specification.
* @return a specification which is protected from blocking calls specified by the
* user.
*/
static Async fromSync(Sync syncSpec) {
List<McpServerFeatures.AsyncToolSpecification> tools = new ArrayList<>();
for (var tool : syncSpec.tools()) {
tools.add(AsyncToolSpecification.fromSync(tool));
}
Map<String, AsyncResourceSpecification> resources = new HashMap<>();
syncSpec.resources().forEach((key, resource) -> {
resources.put(key, AsyncResourceSpecification.fromSync(resource));
});
Map<String, AsyncPromptSpecification> prompts = new HashMap<>();
syncSpec.prompts().forEach((key, prompt) -> {
prompts.put(key, AsyncPromptSpecification.fromSync(prompt));
});
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootChangeConsumers = new ArrayList<>();
for (var rootChangeConsumer : syncSpec.rootsChangeConsumers()) {
rootChangeConsumers.add((exchange, list) -> Mono
.<Void>fromRunnable(() -> rootChangeConsumer.accept(new McpSyncServerExchange(exchange), list))
.subscribeOn(Schedulers.boundedElastic()));
}
return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources,
syncSpec.resourceTemplates(), prompts, rootChangeConsumers, syncSpec.instructions());
}
}
/**
* Synchronous server features specification.
*
* @param serverInfo The server implementation details
* @param serverCapabilities The server capabilities
* @param tools The list of tool specifications
* @param resources The map of resource specifications
* @param resourceTemplates The list of resource templates
* @param prompts The map of prompt specifications
* @param rootsChangeConsumers The list of consumers that will be notified when the
* roots list changes
* @param instructions The server instructions text
*/
record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List<McpServerFeatures.SyncToolSpecification> tools,
Map<String, McpServerFeatures.SyncResourceSpecification> resources,
List<McpSchema.ResourceTemplate> resourceTemplates,
Map<String, McpServerFeatures.SyncPromptSpecification> prompts,
List<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers, String instructions) {
/**
* Create an instance and validate the arguments.
* @param serverInfo The server implementation details
* @param serverCapabilities The server capabilities
* @param tools The list of tool specifications
* @param resources The map of resource specifications
* @param resourceTemplates The list of resource templates
* @param prompts The map of prompt specifications
* @param rootsChangeConsumers The list of consumers that will be notified when
* the roots list changes
* @param instructions The server instructions text
*/
Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List<McpServerFeatures.SyncToolSpecification> tools,
Map<String, McpServerFeatures.SyncResourceSpecification> resources,
List<McpSchema.ResourceTemplate> resourceTemplates,
Map<String, McpServerFeatures.SyncPromptSpecification> prompts,
List<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers,
String instructions) {
Assert.notNull(serverInfo, "Server info must not be null");
this.serverInfo = serverInfo;
this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities
: new McpSchema.ServerCapabilities(null, // experimental
new McpSchema.ServerCapabilities.LoggingCapabilities(), // Enable
// logging
// by
// default
!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 : new ArrayList<>();
this.resources = (resources != null) ? resources : new HashMap<>();
this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : new ArrayList<>();
this.prompts = (prompts != null) ? prompts : new HashMap<>();
this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : new ArrayList<>();
this.instructions = instructions;
}
}
/**
* Specification of a tool with its asynchronous handler function. Tools are the
* primary way for MCP servers to expose functionality to AI models. Each tool
* represents a specific capability, such as:
* <ul>
* <li>Performing calculations
* <li>Accessing external APIs
* <li>Querying databases
* <li>Manipulating files
* <li>Executing system commands
* </ul>
*
* <p>
* Example tool specification: <pre>{@code
* new McpServerFeatures.AsyncToolSpecification(
* new Tool(
* "calculator",
* "Performs mathematical calculations",
* new JsonSchemaObject()
* .required("expression")
* .property("expression", JsonSchemaType.STRING)
* ),
* (exchange, args) -> {
* String expr = (String) args.get("expression");
* return Mono.fromSupplier(() -> evaluate(expr))
* .map(result -> new CallToolResult("Result: " + result));
* }
* )
* }</pre>
*
* @param tool The tool definition including name, description, and parameter schema
* @param call The function that implements the tool's logic, receiving arguments and
* returning results. The function's first argument is an
* {@link McpAsyncServerExchange} upon which the server can interact with the
* connected client. The second arguments is a map of tool arguments.
*/
public record AsyncToolSpecification(McpSchema.Tool tool,
BiFunction<McpAsyncServerExchange, Map<String, Object>, Mono<McpSchema.CallToolResult>> call) {
static AsyncToolSpecification fromSync(SyncToolSpecification tool) {
// FIXME: This is temporary, proper validation should be implemented
if (tool == null) {
return null;
}
return new AsyncToolSpecification(tool.tool(),
(exchange, map) -> Mono
.fromCallable(() -> tool.call().apply(new McpSyncServerExchange(exchange), map))
.subscribeOn(Schedulers.boundedElastic()));
}
}
/**
* Specification of a resource with its asynchronous handler function. Resources
* provide context to AI models by exposing data such as:
* <ul>
* <li>File contents
* <li>Database records
* <li>API responses
* <li>System information
* <li>Application state
* </ul>
*
* <p>
* Example resource specification: <pre>{@code
* new McpServerFeatures.AsyncResourceSpecification(
* new Resource("docs", "Documentation files", "text/markdown"),
* (exchange, request) ->
* Mono.fromSupplier(() -> readFile(request.getPath()))
* .map(ReadResourceResult::new)
* )
* }</pre>
*
* @param resource The resource definition including name, description, and MIME type
* @param readHandler The function that handles resource read requests. The function's
* first argument is an {@link McpAsyncServerExchange} upon which the server can
* interact with the connected client. The second arguments is a
* {@link io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest}.
*/
public record AsyncResourceSpecification(McpSchema.Resource resource,
BiFunction<McpAsyncServerExchange, McpSchema.ReadResourceRequest, Mono<McpSchema.ReadResourceResult>> readHandler) {
static AsyncResourceSpecification fromSync(SyncResourceSpecification resource) {
// FIXME: This is temporary, proper validation should be implemented
if (resource == null) {
return null;
}
return new AsyncResourceSpecification(resource.resource(),
(exchange, req) -> Mono
.fromCallable(() -> resource.readHandler().apply(new McpSyncServerExchange(exchange), req))
.subscribeOn(Schedulers.boundedElastic()));
}
}
/**
* Specification of a prompt template with its asynchronous handler function. Prompts
* provide structured templates for AI model interactions, supporting:
* <ul>
* <li>Consistent message formatting
* <li>Parameter substitution
* <li>Context injection
* <li>Response formatting
* <li>Instruction templating
* </ul>
*
* <p>
* Example prompt specification: <pre>{@code
* new McpServerFeatures.AsyncPromptSpecification(
* new Prompt("analyze", "Code analysis template"),
* (exchange, request) -> {
* String code = request.getArguments().get("code");
* return Mono.just(new GetPromptResult(
* "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
* ));
* }
* )
* }</pre>
*
* @param prompt The prompt definition including name and description
* @param promptHandler The function that processes prompt requests and returns
* formatted templates. The function's first argument is an
* {@link McpAsyncServerExchange} upon which the server can interact with the
* connected client. The second arguments is a
* {@link io.modelcontextprotocol.spec.McpSchema.GetPromptRequest}.
*/
public record AsyncPromptSpecification(McpSchema.Prompt prompt,
BiFunction<McpAsyncServerExchange, McpSchema.GetPromptRequest, Mono<McpSchema.GetPromptResult>> promptHandler) {
static AsyncPromptSpecification fromSync(SyncPromptSpecification prompt) {
// FIXME: This is temporary, proper validation should be implemented
if (prompt == null) {
return null;
}
return new AsyncPromptSpecification(prompt.prompt(),
(exchange, req) -> Mono
.fromCallable(() -> prompt.promptHandler().apply(new McpSyncServerExchange(exchange), req))
.subscribeOn(Schedulers.boundedElastic()));
}
}
/**
* Specification of a tool with its synchronous handler function. Tools are the
* primary way for MCP servers to expose functionality to AI models. Each tool
* represents a specific capability, such as:
* <ul>
* <li>Performing calculations
* <li>Accessing external APIs
* <li>Querying databases
* <li>Manipulating files
* <li>Executing system commands
* </ul>
*
* <p>
* Example tool specification: <pre>{@code
* new McpServerFeatures.SyncToolSpecification(
* new Tool(
* "calculator",
* "Performs mathematical calculations",
* new JsonSchemaObject()
* .required("expression")
* .property("expression", JsonSchemaType.STRING)
* ),
* (exchange, args) -> {
* String expr = (String) args.get("expression");
* return new CallToolResult("Result: " + evaluate(expr));
* }
* )
* }</pre>
*
* @param tool The tool definition including name, description, and parameter schema
* @param call The function that implements the tool's logic, receiving arguments and
* returning results. The function's first argument is an
* {@link McpSyncServerExchange} upon which the server can interact with the connected
* client. The second arguments is a map of arguments passed to the tool.
*/
public record SyncToolSpecification(McpSchema.Tool tool,
BiFunction<McpSyncServerExchange, Map<String, Object>, McpSchema.CallToolResult> call) {
}
/**
* Specification of a resource with its synchronous handler function. Resources
* provide context to AI models by exposing data such as:
* <ul>
* <li>File contents
* <li>Database records
* <li>API responses
* <li>System information
* <li>Application state
* </ul>
*
* <p>
* Example resource specification: <pre>{@code
* new McpServerFeatures.SyncResourceSpecification(
* new Resource("docs", "Documentation files", "text/markdown"),
* (exchange, request) -> {
* String content = readFile(request.getPath());
* return new ReadResourceResult(content);
* }
* )
* }</pre>
*
* @param resource The resource definition including name, description, and MIME type
* @param readHandler The function that handles resource read requests. The function's
* first argument is an {@link McpSyncServerExchange} upon which the server can
* interact with the connected client. The second arguments is a
* {@link io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest}.
*/
public record SyncResourceSpecification(McpSchema.Resource resource,
BiFunction<McpSyncServerExchange, McpSchema.ReadResourceRequest, McpSchema.ReadResourceResult> readHandler) {
}
/**
* Specification of a prompt template with its synchronous handler function. Prompts
* provide structured templates for AI model interactions, supporting:
* <ul>
* <li>Consistent message formatting
* <li>Parameter substitution
* <li>Context injection
* <li>Response formatting
* <li>Instruction templating
* </ul>
*
* <p>
* Example prompt specification: <pre>{@code
* new McpServerFeatures.SyncPromptSpecification(
* new Prompt("analyze", "Code analysis template"),
* (exchange, request) -> {
* String code = request.getArguments().get("code");
* return new GetPromptResult(
* "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
* );
* }
* )
* }</pre>
*
* @param prompt The prompt definition including name and description
* @param promptHandler The function that processes prompt requests and returns
* formatted templates. The function's first argument is an
* {@link McpSyncServerExchange} upon which the server can interact with the connected
* client. The second arguments is a
* {@link io.modelcontextprotocol.spec.McpSchema.GetPromptRequest}.
*/
public record SyncPromptSpecification(McpSchema.Prompt prompt,
BiFunction<McpSyncServerExchange, McpSchema.GetPromptRequest, McpSchema.GetPromptResult> promptHandler) {
}
}

View File

@@ -0,0 +1,394 @@
package com.xspaceagi.mcp.infra.server;
import com.fasterxml.jackson.core.type.TypeReference;
import com.xspaceagi.system.spec.utils.TimeWheel;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpServerTransport;
import io.modelcontextprotocol.spec.McpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import reactor.core.publisher.MonoSink;
import reactor.core.publisher.Sinks;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/**
* Represents a Model Control Protocol (MCP) session on the server side. It manages
* bidirectional JSON-RPC communication with the client.
*/
public class McpServerSession implements McpSession {
private static final Logger logger = LoggerFactory.getLogger(McpServerSession.class);
private final ConcurrentHashMap<Object, MonoSink<McpSchema.JSONRPCResponse>> pendingResponses = new ConcurrentHashMap<>();
private final String id;
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 Sinks.One<McpAsyncServerExchange> exchangeSink = Sinks.one();
private final AtomicReference<McpSchema.ClientCapabilities> clientCapabilities = new AtomicReference<>();
private final AtomicReference<McpSchema.Implementation> clientInfo = new AtomicReference<>();
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);
private McpAsyncServer mcpAsyncServer;
private Long lastActivityTime = System.currentTimeMillis();
/**
* Creates a new server session with the given parameters and the transport to use.
*
* @param id session id
* @param transport the transport to use
* @param initHandler called when a
* {@link io.modelcontextprotocol.spec.McpSchema.InitializeRequest} is received by the
* server
* @param initNotificationHandler called when a
* {@link McpSchema.METHOD_NOTIFICATION_INITIALIZED} is received.
* @param requestHandlers map of request handlers to use
* @param notificationHandlers map of notification handlers to use
*/
public McpServerSession(String id, McpServerTransport transport, InitRequestHandler initHandler,
InitNotificationHandler initNotificationHandler, Map<String, RequestHandler<?>> requestHandlers,
Map<String, NotificationHandler> notificationHandlers) {
this.id = id;
this.transport = transport;
this.initRequestHandler = initHandler;
this.initNotificationHandler = initNotificationHandler;
this.requestHandlers = requestHandlers;
this.notificationHandlers = notificationHandlers;
}
/**
* Retrieve the session id.
*
* @return session id
*/
public String getId() {
return this.id;
}
/**
* Called upon successful initialization sequence between the client and the server
* with the client capabilities and information.
* <p>
* <a href=
* "https://github.com/modelcontextprotocol/specification/blob/main/docs/specification/basic/lifecycle.md#initialization">Initialization
* Spec</a>
*
* @param clientCapabilities the capabilities the connected client provides
* @param clientInfo the 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> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef) {
String requestId = this.generateRequestId();
return Mono.<McpSchema.JSONRPCResponse>create(sink -> {
this.pendingResponses.put(requestId, sink);
McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method,
requestId, requestParams);
this.transport.sendMessage(jsonrpcRequest).subscribe(v -> {
}, error -> {
this.pendingResponses.remove(requestId);
sink.error(error);
});
}).timeout(Duration.ofSeconds(10)).handle((jsonRpcResponse, sink) -> {
if (jsonRpcResponse.error() != null) {
sink.error(new McpError(jsonRpcResponse.error()));
} else {
if (typeRef.getType().equals(Void.class)) {
sink.complete();
} else {
sink.next(this.transport.unmarshalFrom(jsonRpcResponse.result(), typeRef));
}
}
});
}
@Override
public Mono<Void> sendNotification(String method, Object params) {
McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
method, params);
return this.transport.sendMessage(jsonrpcNotification);
}
/**
* Called by the {@link McpServerTransportProvider} once the session is determined.
* The purpose of this method is to dispatch the message to an appropriate handler as
* specified by the MCP server implementation
* ({@link io.modelcontextprotocol.server.McpAsyncServer} or
* {@link io.modelcontextprotocol.server.McpSyncServer}) via
* {@link McpServerSession.Factory} that the server creates.
*
* @param message the incoming JSON-RPC message
* @return a Mono that completes when the message is processed
*/
public Mono<Void> handle(McpSchema.JSONRPCMessage message) {
lastActivityTime = System.currentTimeMillis();
return Mono.defer(() -> {
// TODO handle errors for communication to without initialization happening
// first
if (message instanceof McpSchema.JSONRPCResponse response) {
logger.debug("Received Response: {}", response);
var sink = pendingResponses.remove(response.id());
if (sink == null) {
logger.warn("Unexpected response for unknown id {}", response.id());
} else {
sink.success(response);
}
return Mono.empty();
} else if (message instanceof McpSchema.JSONRPCRequest request) {
logger.debug("Received request: {}", request);
return handleIncomingRequest(request).onErrorResume(error -> {
var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
error.getMessage(), null));
// TODO: Should the error go to SSE or back as POST return?
return this.transport.sendMessage(errorResponse).then(Mono.empty());
}).flatMap(this.transport::sendMessage);
} else if (message instanceof McpSchema.JSONRPCNotification notification) {
// TODO handle errors for communication to without initialization
// happening first
logger.debug("Received notification: {}", notification);
// TODO: in case of error, should the POST request be signalled?
return handleIncomingNotification(notification)
.doOnError(error -> logger.error("Error handling notification: {}", error.getMessage()));
} else {
logger.warn("Received unknown message type: {}", message);
return Mono.empty();
}
});
}
/**
* Handles an incoming JSON-RPC request by routing it to the appropriate handler.
*
* @param request The incoming JSON-RPC request
* @return A Mono containing the JSON-RPC response
*/
private Mono<McpSchema.JSONRPCResponse> handleIncomingRequest(McpSchema.JSONRPCRequest request) {
return Mono.defer(() -> {
Mono<?> resultMono;
if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
// TODO handle situation where already initialized!
McpSchema.InitializeRequest initializeRequest = transport.unmarshalFrom(request.params(),
new TypeReference<McpSchema.InitializeRequest>() {
});
this.state.lazySet(STATE_INITIALIZING);
this.init(initializeRequest.capabilities(), initializeRequest.clientInfo());
resultMono = this.initRequestHandler.handle(initializeRequest);
} else {
// TODO handle errors for communication to this session without
// initialization happening first
var handler = this.requestHandlers.get(request.method());
if (handler == null) {
logger.warn("Method not found: {}", request.method());
MethodNotFoundError error = getMethodNotFoundError(request.method());
return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND,
error.message(), error.data())));
}
resultMono = this.exchangeSink.asMono().flatMap(exchange -> handler.handle(exchange, request.params()));
}
return resultMono
.map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null))
.onErrorResume(error -> {
logger.warn("Error handling request: {}", error.getMessage());
return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
null, new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
error.getMessage(), null)));
}); // TODO: add error message
// through the data field
});
}
/**
* Handles an incoming JSON-RPC notification by routing it to the appropriate handler.
*
* @param notification The incoming JSON-RPC notification
* @return A Mono that completes when the notification is processed
*/
private Mono<Void> handleIncomingNotification(McpSchema.JSONRPCNotification notification) {
return Mono.defer(() -> {
if (McpSchema.METHOD_NOTIFICATION_INITIALIZED.equals(notification.method())) {
this.state.lazySet(STATE_INITIALIZED);
exchangeSink.tryEmitValue(new McpAsyncServerExchange(this, clientCapabilities.get(), clientInfo.get()));
return this.initNotificationHandler.handle();
}
var handler = notificationHandlers.get(notification.method());
if (handler == null) {
logger.error("No handler registered for notification method: {}", notification.method());
return Mono.empty();
}
return this.exchangeSink.asMono().flatMap(exchange -> handler.handle(exchange, notification.params()));
});
}
record MethodNotFoundError(String method, String message, Object data) {
}
static MethodNotFoundError getMethodNotFoundError(String method) {
switch (method) {
case McpSchema.METHOD_ROOTS_LIST:
return new MethodNotFoundError(method, "Roots not supported",
Map.of("reason", "Client does not have roots capability"));
default:
return new MethodNotFoundError(method, "Method not found: " + method, null);
}
}
public void setMcpAsyncServer(McpAsyncServer mcpAsyncServer) {
this.mcpAsyncServer = mcpAsyncServer;
}
public McpAsyncServer getMcpAsyncServer() {
return mcpAsyncServer;
}
public void startObserving(TimeWheel timeWheel, Consumer<McpServerSession> timeoutCallback) {
if (timeWheel != null) {
timeWheel.schedule((res) -> {
if (System.currentTimeMillis() - lastActivityTime > 120 * 1000) {
timeoutCallback.accept(McpServerSession.this);
} else {
startObserving(timeWheel, timeoutCallback);
}
}, 10);
}
}
@Override
public Mono<Void> closeGracefully() {
return this.transport.closeGracefully();
}
@Override
public void close() {
this.transport.close();
pendingResponses.forEach((id, sink) -> sink.error(new Exception("Session closed")));
}
/**
* Request handler for the initialization request.
*/
public interface InitRequestHandler {
/**
* Handles the initialization request.
*
* @param initializeRequest the initialization request by the client
* @return a Mono that will emit the result of the initialization
*/
Mono<McpSchema.InitializeResult> handle(McpSchema.InitializeRequest initializeRequest);
}
/**
* Notification handler for the initialization notification from the client.
*/
public interface InitNotificationHandler {
/**
* Specifies an action to take upon successful initialization.
*
* @return a Mono that will complete when the initialization is acted upon.
*/
Mono<Void> handle();
}
/**
* A handler for client-initiated notifications.
*/
public interface NotificationHandler {
/**
* 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 Mono that completes once the notification is handled.
*/
Mono<Void> handle(McpAsyncServerExchange exchange, Object params);
}
/**
* A handler for client-initiated requests.
*
* @param <T> the type of the response that is expected as a result of handling the
* request.
*/
public interface RequestHandler<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 Mono that will emit the response to the request.
*/
Mono<T> handle(McpAsyncServerExchange exchange, Object params);
}
/**
* Factory for creating server sessions which delegate to a provided 1:1 transport
* with a connected client.
*/
@FunctionalInterface
public interface Factory {
/**
* Creates a new 1:1 representation of the client-server interaction.
*
* @param sessionTransport the transport to use for communication with the client.
* @return a new server session.
*/
McpServerSession create(McpServerTransport sessionTransport);
}
}

View File

@@ -0,0 +1,58 @@
package com.xspaceagi.mcp.infra.server;
import java.util.Map;
import reactor.core.publisher.Mono;
/**
* The core building block providing the server-side MCP transport. Implement this
* interface to bridge between a particular server-side technology and the MCP server
* transport layer.
*
* <p>
* The lifecycle of the provider dictates that it be created first, upon application
* startup, and then passed into either
* {@link io.modelcontextprotocol.server.McpServer#sync(McpServerTransportProvider)} or
* {@link io.modelcontextprotocol.server.McpServer#async(McpServerTransportProvider)}. As
* a result of the MCP server creation, the provider will be notified of a
* {@link McpServerSession.Factory} which will be used to handle a 1:1 communication
* between a newly connected client and the server. The provider's responsibility is to
* create instances of {@link McpServerTransport} that the session will utilise during the
* session lifetime.
*
* <p>
* Finally, the {@link McpServerTransport}s can be closed in bulk when {@link #close()} or
* {@link #closeGracefully()} are called as part of the normal application shutdown event.
* Individual {@link McpServerTransport}s can also be closed on a per-session basis, where
* the {@link McpServerSession#close()} or {@link McpServerSession#closeGracefully()}
* closes the provided transport.
*
* @author Dariusz Jędrzejczyk
*/
public interface McpServerTransportProvider {
/**
* Sends a notification to all connected clients.
* @param method the name of the notification method to be called on the clients
* @param params parameters to be sent with the notification
* @return a Mono that completes when the notification has been broadcast
* @see McpSession#sendNotification(String, Map)
*/
Mono<Void> notifyClients(String method, Object params);
/**
* Immediately closes all the transports with connected clients and releases any
* associated resources.
*/
default void close() {
this.closeGracefully().subscribe();
}
/**
* Gracefully closes all the transports with connected clients and releases any
* associated resources asynchronously.
* @return a {@link Mono<Void>} that completes when the connections have been closed.
*/
Mono<Void> closeGracefully();
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package com.xspaceagi.mcp.infra.server;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
/**
* Represents a synchronous exchange with a Model Context Protocol (MCP) client. The
* exchange provides methods to interact with the client and query its capabilities.
*
* @author Dariusz Jędrzejczyk
* @author Christian Tzolov
*/
public class McpSyncServerExchange {
private final McpAsyncServerExchange exchange;
/**
* Create a new synchronous exchange with the client using the provided asynchronous
* implementation as a delegate.
* @param exchange The asynchronous exchange to delegate to.
*/
public McpSyncServerExchange(McpAsyncServerExchange exchange) {
this.exchange = exchange;
}
/**
* Get the client capabilities that define the supported features and functionality.
* @return The client capabilities
*/
public McpSchema.ClientCapabilities getClientCapabilities() {
return this.exchange.getClientCapabilities();
}
/**
* Get the client implementation information.
* @return The client implementation details
*/
public McpSchema.Implementation getClientInfo() {
return this.exchange.getClientInfo();
}
/**
* Create a new message using the sampling capabilities of the client. The Model
* Context Protocol (MCP) provides a standardized way for servers to request LLM
* sampling (“completions” or “generations”) from language models via clients. This
* flow allows clients to maintain control over model access, selection, and
* permissions while enabling servers to leverage AI capabilities—with no server API
* keys necessary. Servers can request text or image-based interactions and optionally
* include context from MCP servers in their prompts.
* @param createMessageRequest The request to create a new message
* @return A result containing the details of the sampling response
* @see McpSchema.CreateMessageRequest
* @see McpSchema.CreateMessageResult
* @see <a href=
* "https://spec.modelcontextprotocol.io/specification/client/sampling/">Sampling
* Specification</a>
*/
public McpSchema.CreateMessageResult createMessage(McpSchema.CreateMessageRequest createMessageRequest) {
return this.exchange.createMessage(createMessageRequest).block();
}
/**
* Retrieves the list of all roots provided by the client.
* @return The list of roots result.
*/
public McpSchema.ListRootsResult listRoots() {
return this.exchange.listRoots().block();
}
/**
* Retrieves a paginated list of roots provided by the client.
* @param cursor Optional pagination cursor from a previous list request
* @return The list of roots result
*/
public McpSchema.ListRootsResult listRoots(String cursor) {
return this.exchange.listRoots(cursor).block();
}
/**
* Send a logging message notification to all connected clients. Messages below the
* current minimum logging level will be filtered out.
* @param loggingMessageNotification The logging message to send
*/
public void loggingNotification(LoggingMessageNotification loggingMessageNotification) {
this.exchange.loggingNotification(loggingMessageNotification).block();
}
}

View File

@@ -0,0 +1,384 @@
package com.xspaceagi.mcp.infra.server;
import com.alibaba.fastjson2.JSON;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import com.xspaceagi.mcp.infra.client.McpAsyncClientWrapper;
import com.xspaceagi.mcp.infra.rpc.McpDeployRpcService;
import com.xspaceagi.mcp.sdk.IMcpApiService;
import com.xspaceagi.mcp.sdk.dto.McpDto;
import com.xspaceagi.mcp.sdk.dto.McpExecuteRequest;
import com.xspaceagi.mcp.sdk.dto.McpImageContent;
import com.xspaceagi.mcp.sdk.dto.McpLogContent;
import com.xspaceagi.mcp.sdk.enums.InstallTypeEnum;
import com.xspaceagi.mcp.sdk.enums.McpContentTypeEnum;
import com.xspaceagi.system.application.dto.TenantConfigDto;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
import com.xspaceagi.system.application.service.UserApplicationService;
import com.xspaceagi.system.sdk.common.TraceContext;
import com.xspaceagi.system.sdk.service.UserAccessKeyApiService;
import com.xspaceagi.system.sdk.service.dto.UserAccessKeyDto;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import com.xspaceagi.system.spec.utils.TimeWheel;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpServerTransport;
import io.modelcontextprotocol.util.Assert;
import jakarta.annotation.Resource;
import org.apache.commons.collections4.CollectionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
public class WebMvcSseServerTransportProvider implements McpServerTransportProvider {
private static final Logger logger = LoggerFactory.getLogger(WebMvcSseServerTransportProvider.class);
private final ObjectMapper objectMapper;
private final String messageEndpoint;
private final String sseEndpoint;
private final String baseUrl;
private final RouterFunction<ServerResponse> routerFunction;
private final ConcurrentHashMap<String, McpServerSession> sessions;
private volatile boolean isClosing;
@Resource
private IMcpApiService mcpApiService;
@Resource
private TenantConfigApplicationService tenantConfigApplicationService;
@Resource
private UserApplicationService userApplicationService;
@Resource
private UserAccessKeyApiService userAccessKeyApiService;
@Resource
private McpDeployRpcService deployRpcService;
@Resource
private TimeWheel timeWheel;
public WebMvcSseServerTransportProvider(ObjectMapper objectMapper, String messageEndpoint, String sseEndpoint) {
this(objectMapper, "", messageEndpoint, sseEndpoint);
}
public WebMvcSseServerTransportProvider(ObjectMapper objectMapper, String baseUrl, String messageEndpoint, String sseEndpoint) {
this.sessions = new ConcurrentHashMap();
this.isClosing = false;
Assert.notNull(objectMapper, "ObjectMapper must not be null");
Assert.notNull(baseUrl, "Message base URL must not be null");
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
Assert.notNull(sseEndpoint, "SSE endpoint must not be null");
this.objectMapper = objectMapper;
this.baseUrl = baseUrl;
this.messageEndpoint = messageEndpoint;
this.sseEndpoint = sseEndpoint;
this.routerFunction = RouterFunctions.route().GET(this.sseEndpoint, this::handleSseConnection).POST(this.messageEndpoint, this::handleMessage).build();//.POST(this.messageEndpoint, this::handleMessage)
}
public Mono<Void> notifyClients(String method, Object params) {
if (this.sessions.isEmpty()) {
logger.debug("No active sessions to broadcast message to");
return Mono.empty();
} else {
logger.debug("Attempting to broadcast message to {} active sessions", this.sessions.size());
return Flux.fromIterable(this.sessions.values()).flatMap((session) -> {
return session.sendNotification(method, params).doOnError((e) -> {
logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage());
}).onErrorComplete();
}).then();
}
}
public Mono<Void> closeGracefully() {
return Flux.fromIterable(this.sessions.values()).doFirst(() -> {
this.isClosing = true;
logger.debug("Initiating graceful shutdown with {} active sessions", this.sessions.size());
}).flatMap(McpServerSession::closeGracefully).then().doOnSuccess((v) -> {
logger.debug("Graceful shutdown completed");
});
}
public RouterFunction<ServerResponse> getRouterFunction() {
return this.routerFunction;
}
private ServerResponse handleSseConnection(ServerRequest request) {
if (this.isClosing) {
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");
} else {
String ak = request.param("ak").orElse(null);
UserAccessKeyDto userAccessKeyDto = this.userAccessKeyApiService.queryAccessKey(ak);
if (userAccessKeyDto == null || userAccessKeyDto.getTargetType() != UserAccessKeyDto.AKTargetType.Mcp) {
return ServerResponse.status(HttpStatus.UNAUTHORIZED).body(new McpError("Invalid ak"));
}
McpDto deployedMcp = mcpApiService.getDeployedMcp(Long.parseLong(userAccessKeyDto.getTargetId()), null);
if (deployedMcp == null) {
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body(new McpError("Mcp server is unavailable"));
}
String sessionId = UUID.randomUUID().toString();
logger.info("Creating new SSE connection for session: {}, mcp id: {}, mcp name: {}", sessionId, deployedMcp.getId(), deployedMcp.getName());
try {
return ServerResponse.sse((sseBuilder) -> {
sseBuilder.onComplete(() -> {
logger.debug("SSE connection completed for session: {}", sessionId);
this.sessions.remove(sessionId);
});
sseBuilder.onTimeout(() -> {
logger.debug("SSE connection timed out for session: {}", sessionId);
this.sessions.remove(sessionId);
});
sseBuilder.onError(throwable -> {
logger.error("SSE connection error for session: {}", sessionId, throwable);
this.sessions.remove(sessionId);
});
WebMvcMcpSessionTransport sessionTransport = new WebMvcMcpSessionTransport(sessionId, sseBuilder);
McpAsyncServer mcpAsyncServer = buildMcpAsyncServer(userAccessKeyDto, deployedMcp, sessionId);
McpServerSession session = mcpAsyncServer.getSessionFactory().create(sessionTransport);
session.setMcpAsyncServer(mcpAsyncServer);
this.sessions.put(sessionId, session);
session.startObserving(timeWheel, (s) -> {
logger.debug("SSE connection not alive for session: {}", sessionId);
McpServerSession removeSession = this.sessions.remove(sessionId);
if (removeSession != null) {
removeSession.close();
}
mcpAsyncServer.close();
});
try {
sseBuilder.id(sessionId).event("endpoint").data(this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId);
} catch (Exception var6) {
logger.error("Failed to send initial endpoint event: {}", var6.getMessage());
sseBuilder.error(var6);
}
}, Duration.ZERO);
} catch (Exception var4) {
logger.error("Failed to send initial endpoint event to session {}: {}", sessionId, var4.getMessage());
this.sessions.remove(sessionId);
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
private McpAsyncServer buildMcpAsyncServer(UserAccessKeyDto userAccessKeyDto, McpDto deployedMcp, String sessionId) {
//代理mcp
if (deployedMcp.getInstallType() != InstallTypeEnum.COMPONENT) {
Mono<McpAsyncClientWrapper> mcpAsyncClientMono;
if (deployedMcp.getInstallType() == InstallTypeEnum.SSE) {
mcpAsyncClientMono = deployRpcService.getMcpAsyncClientForSSE(deployedMcp.getId().toString(), "mcp-server-" + deployedMcp.getId(), deployedMcp.getDeployedConfig().getServerConfig());
} else {
mcpAsyncClientMono = deployRpcService.getMcpAsyncClient(deployedMcp.getId().toString(), "mcp-server-" + deployedMcp.getId(), deployedMcp.getDeployedConfig().getServerConfig());
}
McpAsyncServer mcpAsyncServer = McpServer.async(this).build(true);
mcpAsyncServer.setMcpProxyAsyncClientMono(mcpAsyncClientMono);
return mcpAsyncServer;
}
//平台组件mcp
UserDto userDto = userApplicationService.queryById(userAccessKeyDto.getUserId());
TenantConfigDto tenantConfigDto = tenantConfigApplicationService.getTenantConfig(userDto.getTenantId());
McpServer.AsyncSpecification capabilities = McpServer.async(this)
.serverInfo(deployedMcp.getServerName() == null ? deployedMcp.getName() : deployedMcp.getServerName(), "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder()
.resources(false, false) // 启用资源支持
.tools(true) // 启用工具支持
.prompts(false) // 启用提示支持
.logging() // 启用日志支持
.build());
//平台组件只支持tools
List<McpServerFeatures.AsyncToolSpecification> asyncToolSpecifications = new ArrayList<>();
deployedMcp.getDeployedConfig().getTools().forEach(tool -> {
var aSyncToolSpecification = new McpServerFeatures.AsyncToolSpecification(new McpSchema.Tool(tool.getName(), tool.getDescription(), tool.getJsonSchema()),
(exchange, args) -> {
TraceContext traceContext = userAccessKeyDto.getConfig().getTraceContext();
String traceId;
String conversationId;
if (traceContext == null) {
traceId = UUID.randomUUID().toString().replace("-", "");
conversationId = sessionId;
traceContext = TraceContext.builder()
.traceId(traceId)
.tenantId(userDto.getTenantId())
.userId(userDto.getId())
.billUserId(userDto.getId())
.userName(userDto.getUserName())
.nickName(userDto.getNickName())
.conversationId(sessionId)
.enableSubscription(tenantConfigDto.getEnableSubscription() != null && tenantConfigDto.getEnableSubscription() == 1)
.traceTargets(Lists.newArrayList(TraceContext.TraceTarget.builder()
.targetId(deployedMcp.getId().toString())
.targetType(TraceContext.TraceTargetType.Mcp)
.icon(deployedMcp.getIcon())
.description(deployedMcp.getDescription())
.name(deployedMcp.getName())
.build()))
.build();
} else {
traceId = traceContext.getTraceId();
conversationId = traceContext.getConversationId();
}
McpExecuteRequest mcpExecuteRequest = McpExecuteRequest.builder()
.requestId(traceId)
.sessionId(conversationId)
.mcpDto(deployedMcp)
.executeType(McpExecuteRequest.ExecuteTypeEnum.TOOL)
.name(tool.getName())
.keepAlive(true)
.user(userDto)
.traceContext(traceContext)
.params(args)
.build();
return Mono.create(sink -> mcpApiService.execute(mcpExecuteRequest).doOnNext(mcpExecuteOutput -> {
if (!mcpExecuteOutput.isSuccess()) {
sink.error(BizException.of(ErrorCodeEnum.ERROR_REQUEST, BizExceptionCodeEnum.remoteServiceMessage,
mcpExecuteOutput.getMessage() != null ? mcpExecuteOutput.getMessage() : ""));
} else if (CollectionUtils.isNotEmpty(mcpExecuteOutput.getResult()) && !(mcpExecuteOutput.getResult().get(0) instanceof McpLogContent)) {
sink.success(new McpSchema.CallToolResult(mcpExecuteOutput.getResult().stream().map(mcpContent -> {
if (mcpContent.getType() == McpContentTypeEnum.TEXT) {
return new McpSchema.TextContent(mcpContent.getData());
}
if (mcpContent.getType() == McpContentTypeEnum.IMAGE) {
McpImageContent mcpImageContent = (McpImageContent) mcpContent;
return new McpSchema.ImageContent(List.of(McpSchema.Role.ASSISTANT), mcpImageContent.getPriority(), mcpImageContent.getData(), mcpImageContent.getMimeType());
}
return null;
}).collect(Collectors.toList()), false));
} else {
//LOG
if (CollectionUtils.isNotEmpty(mcpExecuteOutput.getResult()) && (mcpExecuteOutput.getResult().get(0) instanceof McpLogContent)) {
exchange.loggingNotification( // Use the exchange to send log messages
McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.DEBUG)
.logger(tool.getName())
.data(JSON.toJSONString(mcpExecuteOutput.getResult()))
.build())
.subscribe();
}
}
}).doOnError(sink::error).subscribe());
}
);
asyncToolSpecifications.add(aSyncToolSpecification);
});
return capabilities.tools(asyncToolSpecifications).build(false);
}
private ServerResponse handleMessage(ServerRequest request) {//改造成异步
return ServerResponse.async(Mono.create(sink -> {
if (this.isClosing) {
sink.success(ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"));
} else if (!request.param("sessionId").isPresent()) {
sink.success(ServerResponse.badRequest().body(new McpError("Session ID missing in message endpoint")));
} else {
String sessionId = request.param("sessionId").get();
McpServerSession session = this.sessions.get(sessionId);
if (session == null) {
logger.debug("Session not found: {}", sessionId);
sink.success(ServerResponse.status(HttpStatus.NOT_FOUND).body(new McpError("Session not found: " + sessionId)));
} else {
try {
String body = request.body(String.class);
logger.info("Received mcp message: {}, sessionId :{}", body, sessionId);
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, body);
session.handle(message)
.onErrorResume(error -> {
logger.error("onErrorResume handling, mcp message: {}, err {}", message, error.getMessage());
return Mono.just(new McpError(error)).then();
})
.doOnSuccess(res -> {
logger.debug("Sent mcp message: {}, sessionId :{}", message, sessionId);
sink.success(ServerResponse.ok().build());
})
.doOnError(error -> {
logger.error("Error handling, mcp message: {}, err {}", message, error.getMessage());
sink.success(ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(error.getMessage())));
})
.subscribe();
} catch (IOException | IllegalArgumentException var6) {
logger.error("Failed to deserialize message: {}", var6.getMessage());
sink.success(ServerResponse.badRequest().body(new McpError("Invalid message format")));
} catch (Exception var7) {
logger.error("Error handling message: {}", var7.getMessage());
sink.success(ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(var7.getMessage())));
}
}
}
}), Duration.ofMinutes(30));
}
private class WebMvcMcpSessionTransport implements McpServerTransport {
private final String sessionId;
private final ServerResponse.SseBuilder sseBuilder;
WebMvcMcpSessionTransport(String sessionId, ServerResponse.SseBuilder sseBuilder) {
this.sessionId = sessionId;
this.sseBuilder = sseBuilder;
WebMvcSseServerTransportProvider.logger.debug("Session transport {} initialized with SSE builder", sessionId);
}
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
return Mono.fromRunnable(() -> {
try {
String jsonText = WebMvcSseServerTransportProvider.this.objectMapper.writeValueAsString(message);
this.sseBuilder.id(this.sessionId).event("message").data(jsonText);
logger.info("Sending message to session {}: {}", this.sessionId, jsonText);
WebMvcSseServerTransportProvider.logger.debug("Message sent to session {}", this.sessionId);
} catch (Exception var3) {
WebMvcSseServerTransportProvider.logger.error("Failed to send message to session {}: {}", this.sessionId, var3.getMessage());
this.sseBuilder.error(var3);
}
});
}
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
return WebMvcSseServerTransportProvider.this.objectMapper.convertValue(data, typeRef);
}
public Mono<Void> closeGracefully() {
return Mono.fromRunnable(() -> {
WebMvcSseServerTransportProvider.logger.debug("Closing session transport: {}", this.sessionId);
try {
this.sseBuilder.complete();
WebMvcSseServerTransportProvider.logger.debug("Successfully completed SSE builder for session {}", this.sessionId);
} catch (Exception var2) {
WebMvcSseServerTransportProvider.logger.warn("Failed to complete SSE builder for session {}: {}", this.sessionId, var2.getMessage());
}
});
}
public void close() {
try {
this.sseBuilder.complete();
WebMvcSseServerTransportProvider.logger.debug("Successfully completed SSE builder for session {}", this.sessionId);
} catch (Exception var2) {
WebMvcSseServerTransportProvider.logger.warn("Failed to complete SSE builder for session {}: {}", this.sessionId, var2.getMessage());
}
}
}
}

View File

@@ -0,0 +1,11 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>app-platform-mcp-sdk</artifactId>
<name>app-platform-mcp-sdk</name>
</project>

View File

@@ -0,0 +1,59 @@
package com.xspaceagi.mcp.sdk;
import com.xspaceagi.mcp.sdk.dto.McpDto;
import com.xspaceagi.mcp.sdk.dto.McpExecuteOutput;
import com.xspaceagi.mcp.sdk.dto.McpExecuteRequest;
import com.xspaceagi.system.sdk.service.dto.UserAccessKeyDto;
import reactor.core.publisher.Flux;
public interface IMcpApiService {
/**
* 获取已部署的MCP详情
*
* @param id
* @param spaceId 可不传,传了会根据空间过滤
* @return
*/
McpDto getDeployedMcp(Long id, Long spaceId);
Flux<McpExecuteOutput> execute(McpExecuteRequest mcpExecuteRequestDto);
Long addAndDeployMcp(Long userId, Long spaceId, McpDto mcpDto);
//生态市场使用
Long deployOfficialMcp(McpDto mcpDto);
void stopOfficialMcp(Long id);
Long deployProxyMcp(McpDto mcpDto);
String getExportMcpServerConfig(Long userId, Long mcpId, UserAccessKeyDto.UserAccessKeyConfig userAccessKeyConfig);
/**
* 统计 MCP 总数
*
* @return MCP 总数
*/
Long countTotalMcps();
/**
* 管理端查询MCP列表
*
* @param pageNo 页码
* @param pageSize 每页大小
* @param name 名称模糊搜索
* @param creatorIds 创建人ID列表
* @param spaceId 空间ID
* @return MCP分页数据
*/
com.baomidou.mybatisplus.core.metadata.IPage<McpDto> queryListForManage(Integer pageNo, Integer pageSize, String name, java.util.List<Long> creatorIds, Long spaceId);
/**
* 管理端删除MCP
*
* @param id MCP ID
*/
void deleteForManage(Long id);
}

View File

@@ -0,0 +1,26 @@
package com.xspaceagi.mcp.sdk.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class CreatorDto {
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "用户名")
private String userName;
@Schema(description = "昵称")
private String nickName;
@Schema(description = "头像")
private String avatar;
}

View File

@@ -0,0 +1,41 @@
package com.xspaceagi.mcp.sdk.dto;
import com.xspaceagi.mcp.sdk.enums.McpDataTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class McpArgDto {
@Schema(description = "参数key唯一标识不需要前端传递后台根据配置自动生成")
private String key;
@Schema(description = "参数名称,符合函数命名规则")
private String name;
@Schema(description = "参数详细描述信息")
private String description;
@Schema(description = "数据类型")
private McpDataTypeEnum dataType;
@Schema(description = "是否必须")
private boolean require;
@Schema(description = "下级参数")
private List<McpArgDto> subArgs;
@Schema(description = "绑定值,默认值")
private String bindValue;
@Schema(description = "可选枚举范围")
private List<String> enums;
}

View File

@@ -0,0 +1,33 @@
package com.xspaceagi.mcp.sdk.dto;
import com.xspaceagi.mcp.sdk.enums.McpComponentTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class McpComponentDto {
@Schema(description = "组件名称")
private String name;
@Schema(description = "组件图标")
private String icon;
@Schema(description = "组件描述")
private String description;
@Schema(description = "组件类型")
private McpComponentTypeEnum type;
@Schema(description = "关联的组件ID")
private Long targetId; // 关联的组件ID
@Schema(description = "组件原始配置")
private String targetConfig; // 组件原始配置
@Schema(description = "对于通用智能体参数绑定配置")
private String targetBindConfig; // 组件原始配置
@Schema(description = "工具名称", hidden = true)
private String toolName;
}

View File

@@ -0,0 +1,25 @@
package com.xspaceagi.mcp.sdk.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
public class McpConfigDto {
@Schema(description = "MCP服务配置installType为npx、uvx、sse时有效")
private String serverConfig;
@Schema(description = "MCP组件配置installType为component时有效")
private List<McpComponentDto> components;
@Schema(description = "MCP工具列表无需前端传递")
private List<McpToolDto> tools;
@Schema(description = "MCP资源列表无需前端传递")
private List<McpResourceDto> resources;
@Schema(description = "MCP提示词列表无需前端传递")
private List<McpPromptDto> prompts;
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.mcp.sdk.dto;
import com.xspaceagi.mcp.sdk.enums.McpContentTypeEnum;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class McpContent implements Serializable {
private String data;
private McpContentTypeEnum type;
}

View File

@@ -0,0 +1,68 @@
package com.xspaceagi.mcp.sdk.dto;
import com.xspaceagi.mcp.sdk.enums.DeployStatusEnum;
import com.xspaceagi.mcp.sdk.enums.InstallTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
import java.util.List;
@Data
public class McpDto {
@Schema(description = "MCP ID")
private Long id;
@Schema(description = "空间ID")
private Long spaceId;
@Schema(description = "创建者ID")
private Long creatorId;
@Schema(description = "MCP唯一标识")
private String uid;
@Schema(description = "MCP名称")
private String name;
@Schema(description = "MCP Server英文名称", hidden = true)
private String serverName;
@Schema(description = "MCP描述")
private String description;
@Schema(description = "MCP图标")
private String icon;
@Schema(description = "MCP分类例如 Other")
private String category;
@Schema(description = "MCP安装方式")
private InstallTypeEnum installType;
@Schema(description = "MCP部署状态")
private DeployStatusEnum deployStatus;
@Schema(description = "MCP配置信息")
private McpConfigDto mcpConfig;
@Schema(description = "MCP已部署配置信息", hidden = true)
private McpConfigDto deployedConfig;
@Schema(description = "MCP部署时间")
private Date deployed;
@Schema(description = "MCP修改时间")
private Date modified;
@Schema(description = "MCP创建时间")
private Date created;
@Schema(description = "MCP创建者信息")
private CreatorDto creator;
private List<String> permissions;
private boolean isPlatformMcp;
}

View File

@@ -0,0 +1,23 @@
package com.xspaceagi.mcp.sdk.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class McpExecuteOutput {
@Schema(description = "执行结果状态")
private boolean success;
private String message;
private List<McpContent> result;
}

View File

@@ -0,0 +1,49 @@
package com.xspaceagi.mcp.sdk.dto;
import com.xspaceagi.system.sdk.common.TraceContext;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Map;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class McpExecuteRequest implements Serializable {
private String requestId;
@Schema(description = "会话ID", requiredMode = Schema.RequiredMode.REQUIRED)
private String sessionId;
@Schema(description = "用户信息UserDto", requiredMode = Schema.RequiredMode.REQUIRED)
private Object user;
@Schema(description = "MCP详细信息", requiredMode = Schema.RequiredMode.REQUIRED)
private McpDto mcpDto;
@Schema(description = "执行类型", requiredMode = Schema.RequiredMode.REQUIRED)
private ExecuteTypeEnum executeType;
@Schema(description = "MCP工具/资源/提示词名称", requiredMode = Schema.RequiredMode.REQUIRED)
private String name;
@Schema(description = "参数")
private Map<String, Object> params;
@Schema(description = "是否保持会话,保持会话时需要显示调用结束")
private boolean keepAlive;
private TraceContext traceContext;
public enum ExecuteTypeEnum {
TOOL,
RESOURCE,
PROMPT
}
}

View File

@@ -0,0 +1,19 @@
package com.xspaceagi.mcp.sdk.dto;
import com.xspaceagi.mcp.sdk.enums.McpContentTypeEnum;
import lombok.Data;
import java.util.List;
@Data
public class McpImageContent extends McpContent {
private List<String> audience;
private Double priority;
private String mimeType;
@Override
public McpContentTypeEnum getType() {
return McpContentTypeEnum.IMAGE;
}
}

View File

@@ -0,0 +1,11 @@
package com.xspaceagi.mcp.sdk.dto;
import com.xspaceagi.mcp.sdk.enums.McpContentTypeEnum;
public class McpLogContent extends McpContent {
@Override
public McpContentTypeEnum getType() {
return McpContentTypeEnum.TEXT;
}
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.mcp.sdk.dto;
import com.xspaceagi.mcp.sdk.enums.McpContentTypeEnum;
import lombok.Data;
@Data
public class McpPromptContent extends McpContent {
private String role;
private McpContent content;
@Override
public McpContentTypeEnum getType() {
return McpContentTypeEnum.PROMPT;
}
}

View File

@@ -0,0 +1,22 @@
package com.xspaceagi.mcp.sdk.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
public class McpPromptDto {
@Schema(description = "名称")
private String name;
@Schema(description = "描述")
private String description;
@Schema(description = "输入参数")
private List<McpArgDto> inputArgs;
@Schema(description = "输出参数")
private List<McpArgDto> outputArgs;
}

View File

@@ -0,0 +1,25 @@
package com.xspaceagi.mcp.sdk.dto;
import com.xspaceagi.mcp.sdk.enums.McpContentTypeEnum;
import lombok.Data;
import java.util.List;
@Data
public class McpResourceContent extends McpContent {
private String uri;
private String mimeType;
private String blob;
private String name;
private String description;
private Annotations annotations;
@Override
public McpContentTypeEnum getType() {
return McpContentTypeEnum.RESOURCE;
}
public record Annotations(List<String> audience,
Double priority) {
}
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.mcp.sdk.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
public class McpResourceDto {
private String uri;
@Schema(description = "名称")
private String name;
@Schema(description = "描述")
private String description;
private String mimeType;
private McpResourceContent.Annotations annotations;
@Schema(description = "工具输入参数")
private List<McpArgDto> inputArgs;
}

View File

@@ -0,0 +1,10 @@
package com.xspaceagi.mcp.sdk.dto;
import com.xspaceagi.mcp.sdk.enums.McpContentTypeEnum;
public class McpTextContent extends McpContent {
@Override
public McpContentTypeEnum getType() {
return McpContentTypeEnum.TEXT;
}
}

View File

@@ -0,0 +1,25 @@
package com.xspaceagi.mcp.sdk.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
public class McpToolDto {
@Schema(description = "工具名称")
private String name;
@Schema(description = "工具描述")
private String description;
@Schema(description = "工具输入参数")
private List<McpArgDto> inputArgs;
@Schema(description = "工具输出参数")
private List<McpArgDto> outputArgs;
@Schema(description = "工具原始的定义", hidden = true)
private String jsonSchema;
}

View File

@@ -0,0 +1,10 @@
package com.xspaceagi.mcp.sdk.enums;
public enum DeployStatusEnum {
//“部署中”、“已部署”、“部署失败”、“已停止”
Initialization,
Deploying,
Deployed,
DeployFailed,
Stopped
}

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.mcp.sdk.enums;
public enum InstallTypeEnum {
NPX,
UVX,
SSE,
STREAMABLE_HTTP,
COMPONENT;
}

View File

@@ -0,0 +1,5 @@
package com.xspaceagi.mcp.sdk.enums;
public enum McpComponentTypeEnum {
Plugin, Workflow, Knowledge, Table, Agent
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.mcp.sdk.enums;
public enum McpContentTypeEnum {
TEXT("text"),
IMAGE("image"),
RESOURCE("resource"),
PROMPT("prompt");
private String value;
McpContentTypeEnum(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View File

@@ -0,0 +1,14 @@
package com.xspaceagi.mcp.sdk.enums;
public enum McpDataTypeEnum {
String, //文本
Integer, //整型数字
Number, //数字
Boolean, //布尔
Object, //对象
Array_String,//String数组
Array_Integer,//Integer数组
Array_Number,//Number数组
Array_Boolean,//Boolean数组
Array_Object,//Object数组
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-spec</artifactId>
<dependencies>
</dependencies>
</project>

View File

@@ -0,0 +1,104 @@
package com.xspaceagi.mcp.spec.utils;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class UrlExtractUtil {
public static List<String> extractUrls(String text) {
if (JSON.isValid(text)) {
JSONObject jsonObject = JSON.parseObject(text);
String url = parseUrl(jsonObject);
if (url != null) {
return List.of(url);
}
}
List<String> urls = new ArrayList<>();
String regex = "(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
urls.add(matcher.group());
}
return urls;
}
public static Map<String, String> extractHeaders(String serverConfig) {
if (JSON.isValid(serverConfig)) {
JSONObject jsonObject = JSON.parseObject(serverConfig);
return extractHeaders(jsonObject);
}
return null;
}
private static Map<String, String> extractHeaders(JSONObject jsonObject) {
if (jsonObject == null) {
return null;
}
for (String key : jsonObject.keySet()) {
Object value = jsonObject.get(key);
if (key.toLowerCase().equals("headers")) {
if (value instanceof Map) {
Map<String, String> map = (Map<String, String>) value;
((Map<?, ?>) value).forEach((k, v) -> {
map.put(k.toString(), v == null ? "" : v.toString());
});
return map;
}
} else if (value instanceof JSONObject) {
Map<String, String> headers = extractHeaders((JSONObject) value);
if (headers != null) {
return headers;
}
}
}
return null;
}
private static String parseUrl(JSONObject jsonObject) {
for (String key : jsonObject.keySet()) {
Object value = jsonObject.get(key);
String url = parseValue(value);
if (url != null) {
return url;
}
}
return null;
}
private static String parseValue(Object value) {
if (value instanceof String) {
if (value != null && value.toString().startsWith("http://") || value.toString().startsWith("https://")) {
return value.toString();
}
}
if (value instanceof JSONObject) {
return parseUrl((JSONObject) value);
}
if (value instanceof JSONArray) {
return parseUrlFromArray((JSONArray) value);
}
return null;
}
private static String parseUrlFromArray(JSONArray value) {
for (int i = 0; i < value.size(); i++) {
Object item = value.get(i);
String url = parseValue(item);
if (url != null) {
return url;
}
}
return null;
}
}

View File

@@ -0,0 +1,21 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>app-platform-mcp-ui</artifactId>
<name>app-platform-mcp-ui</name>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp-application</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-agent-core-adapter</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,750 @@
package com.xspaceagi.mcp.ui.api.service;
import com.alibaba.fastjson2.JSON;
import com.xspaceagi.agent.core.sdk.IAgentRpcService;
import com.xspaceagi.agent.core.sdk.dto.AgentExecuteRequestDto;
import com.xspaceagi.agent.core.sdk.dto.KnowledgeSearchRequest;
import com.xspaceagi.agent.core.sdk.dto.PluginExecuteRequestDto;
import com.xspaceagi.agent.core.sdk.dto.WorkflowExecuteRequestDto;
import com.xspaceagi.agent.core.sdk.enums.WfExecuteResultTypeEnum;
import com.xspaceagi.agent.core.spec.enums.SearchStrategyEnum;
import com.xspaceagi.compose.sdk.request.DorisTableDataRequest;
import com.xspaceagi.compose.sdk.response.DorisTableDataResponse;
import com.xspaceagi.compose.sdk.service.IComposeDbTableRpcService;
import com.xspaceagi.log.sdk.service.ILogRpcService;
import com.xspaceagi.log.sdk.vo.LogDocument;
import com.xspaceagi.mcp.adapter.application.McpConfigApplicationService;
import com.xspaceagi.mcp.infra.client.McpAsyncClientWrapper;
import com.xspaceagi.mcp.infra.rpc.McpDeployRpcService;
import com.xspaceagi.mcp.sdk.IMcpApiService;
import com.xspaceagi.mcp.sdk.dto.*;
import com.xspaceagi.mcp.sdk.enums.InstallTypeEnum;
import com.xspaceagi.mcp.sdk.enums.McpComponentTypeEnum;
import com.xspaceagi.mcp.sdk.enums.McpContentTypeEnum;
import com.xspaceagi.mcp.sdk.enums.McpDataTypeEnum;
import com.xspaceagi.pricing.sdk.dto.PriceEstimate;
import com.xspaceagi.pricing.sdk.rpc.IPricingRpcService;
import com.xspaceagi.pricing.spec.enums.TargetTypeEnum;
import com.xspaceagi.system.application.dto.TenantConfigDto;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.sdk.service.dto.UserAccessKeyDto;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collectors;
@Slf4j
@Service
public class McpApiServiceImpl implements IMcpApiService {
@Resource
private McpConfigApplicationService mcpConfigApplicationService;
@Resource
private IAgentRpcService iAgentRpcService;
@Resource
private IComposeDbTableRpcService iComposeDbTableRpcService;
@Resource
private McpDeployRpcService mcpDeployRpcService;
@Resource
private ILogRpcService iLogRpcService;
@Resource
private IPricingRpcService iPricingRpcService;
@Override
public McpDto getDeployedMcp(Long id, Long spaceId) {
McpDto deployedMcp = mcpConfigApplicationService.getDeployedMcp(id);
if (deployedMcp == null) {
return null;
}
if (spaceId != null && deployedMcp.getSpaceId() != -1 && !deployedMcp.getSpaceId().equals(spaceId)) {
return null;
}
deployedMcp.setMcpConfig(deployedMcp.getDeployedConfig());
return deployedMcp;
}
@Override
public Flux<McpExecuteOutput> execute(McpExecuteRequest mcpExecuteRequestDto) {
try {
UserDto userDto = (UserDto) mcpExecuteRequestDto.getUser();
RequestContext.setThreadTenantId(userDto.getTenantId());
RequestContext.get().setUser(userDto);
try {
LogDocument logDocument = LogDocument.builder()
.tenantId(userDto.getTenantId())
.id(UUID.randomUUID().toString().replace("-", ""))
.requestId(mcpExecuteRequestDto.getRequestId())
.spaceId(mcpExecuteRequestDto.getMcpDto().getSpaceId())
.userId(userDto.getId())
.userName(userDto.getNickName() == null ? userDto.getUserName() : userDto.getNickName())
.targetType("Mcp")
.targetName(mcpExecuteRequestDto.getName())
.targetId(mcpExecuteRequestDto.getMcpDto().getId().toString())
.conversationId(mcpExecuteRequestDto.getSessionId())
.input(JSON.toJSONString(mcpExecuteRequestDto.getParams()))
.requestStartTime(System.currentTimeMillis())
.build();
Flux<McpExecuteOutput> mcpExecuteOutputFlux = execute0(mcpExecuteRequestDto);
if (mcpExecuteOutputFlux == null) {
return Flux.empty();
}
return mcpExecuteOutputFlux.doOnError(throwable -> {
try {
logDocument.setCreateTime(System.currentTimeMillis());
logDocument.setResultCode("0001");
logDocument.setResultMsg(throwable.getMessage());
logDocument.setRequestEndTime(System.currentTimeMillis());
iLogRpcService.bulkIndex(List.of(logDocument));
} catch (Exception e) {
// 忽略
log.error("MCP log recording error", e);
}
}).doOnNext(result -> {
try {
logDocument.setOutput(JSON.toJSONString(result));
logDocument.setCreateTime(System.currentTimeMillis());
logDocument.setResultCode("0000");
logDocument.setResultMsg("成功");
logDocument.setRequestEndTime(System.currentTimeMillis());
iLogRpcService.bulkIndex(List.of(logDocument));
} catch (Exception e) {
// 忽略
log.error("MCP log recording error", e);
}
});
} catch (Exception e) {
log.error("mcp execute error", e);
return Flux.error(e);
}
} finally {
RequestContext.remove();
}
}
private Flux<McpExecuteOutput> execute0(McpExecuteRequest mcpExecuteRequestDto) {
McpDto mcpDto = mcpExecuteRequestDto.getMcpDto();
if (CollectionUtils.isNotEmpty(mcpDto.getDeployedConfig().getTools()) && mcpExecuteRequestDto.getExecuteType() == McpExecuteRequest.ExecuteTypeEnum.TOOL) {
try {
McpToolDto mcpToolDto = mcpDto.getDeployedConfig().getTools().stream().filter(tool -> tool.getName().equals(mcpExecuteRequestDto.getName())).findFirst().orElse(null);
if (mcpToolDto == null) {
return Flux.just(McpExecuteOutput.builder().success(false).message("未找到工具").build());
}
checkAndUpdateParams(mcpToolDto.getInputArgs(), mcpExecuteRequestDto.getParams());
} catch (Exception e) {
return Flux.just(new McpExecuteOutput(false, e.getMessage(), null));
}
}
if (CollectionUtils.isNotEmpty(mcpDto.getDeployedConfig().getPrompts()) && mcpExecuteRequestDto.getExecuteType() == McpExecuteRequest.ExecuteTypeEnum.PROMPT) {
try {
McpPromptDto mcpPromptDto = mcpDto.getDeployedConfig().getPrompts().stream().filter(tool -> tool.getName().equals(mcpExecuteRequestDto.getName())).findFirst().orElse(null);
checkAndUpdateParams(mcpPromptDto.getInputArgs(), mcpExecuteRequestDto.getParams());
} catch (Exception e) {
return Flux.just(new McpExecuteOutput(false, e.getMessage(), null));
}
}
if (mcpDto.getInstallType() == InstallTypeEnum.COMPONENT) {
if (mcpDto.getMcpConfig() == null || mcpDto.getMcpConfig().getComponents() == null) {
return Flux.just(new McpExecuteOutput(false, "MCP配置不完整", null));
}
McpConfigDto mcpConfig = mcpDto.getDeployedConfig();
//mcpConfig.getComponents()根据toolName转map
Map<String, McpComponentDto> componentMap = mcpConfig.getComponents().stream().collect(Collectors.toMap(McpComponentDto::getToolName, Function.identity(), (a, b) -> a));
McpComponentDto component = componentMap.get(mcpExecuteRequestDto.getName());
if (component == null) {
return Flux.just(new McpExecuteOutput(false, "MCP组件不存在", null));
}
Map<String, Object> params = mcpExecuteRequestDto.getParams();
if (component.getType() == McpComponentTypeEnum.Plugin) {
return executePlugin(mcpDto, component, mcpExecuteRequestDto);
}
if (component.getType() == McpComponentTypeEnum.Workflow) {
return executeWorkflow(mcpDto, component, mcpExecuteRequestDto);
}
if (component.getType() == McpComponentTypeEnum.Table) {
return executeTable(component, mcpExecuteRequestDto);
}
if (component.getType() == McpComponentTypeEnum.Knowledge) {
return searchKnowledge(mcpExecuteRequestDto, component, params);
}
if (component.getType() == McpComponentTypeEnum.Agent) {
return executeAgent(component, mcpExecuteRequestDto);
}
} else {
if (mcpDto.getDeployedConfig() == null || mcpDto.getDeployedConfig().getServerConfig() == null) {
return Flux.just(new McpExecuteOutput(false, "MCP未部署或已停用", null));
}
AtomicReference<Disposable> disposableAtomicReference = new AtomicReference<>();
Sinks.Many<McpExecuteOutput> sink = Sinks.many().multicast().onBackpressureBuffer();
boolean isSSE = mcpDto.getInstallType() == InstallTypeEnum.SSE;
String conversationId = mcpExecuteRequestDto.getSessionId();
McpConfigDto mcpConfig = mcpDto.getMcpConfig();
McpAsyncClientWrapper mcpAsyncClientWrapper;
if (isSSE) {
try {
UserDto userDto = (UserDto) mcpExecuteRequestDto.getUser();
RequestContext.setThreadTenantId(userDto.getTenantId());
mcpAsyncClientWrapper = mcpDeployRpcService.getMcpAsyncClientForSSE(mcpDto.getId().toString(), conversationId, mcpConfig.getServerConfig()).block();
} finally {
RequestContext.remove();
}
} else {
mcpAsyncClientWrapper = mcpDeployRpcService.getMcpAsyncClient(mcpDto.getId().toString(), conversationId, mcpConfig.getServerConfig()).block();
}
Disposable disposable = null;
if (mcpExecuteRequestDto.getExecuteType() == McpExecuteRequest.ExecuteTypeEnum.TOOL) {
disposable = callTool(sink, mcpAsyncClientWrapper, mcpExecuteRequestDto);
} else if (mcpExecuteRequestDto.getExecuteType() == McpExecuteRequest.ExecuteTypeEnum.RESOURCE) {
disposable = readResource(sink, mcpAsyncClientWrapper, mcpExecuteRequestDto);
} else if (mcpExecuteRequestDto.getExecuteType() == McpExecuteRequest.ExecuteTypeEnum.PROMPT) {
disposable = getPrompt(sink, mcpAsyncClientWrapper, mcpExecuteRequestDto);
}
disposableAtomicReference.set(disposable);
return sink.asFlux().doOnCancel(() -> {
if (disposableAtomicReference.get() != null) {
disposableAtomicReference.get().dispose();
}
});
}
return null;
}
private void checkAndUpdateParams(List<McpArgDto> inputArgs, Map<String, Object> params) {
if (inputArgs == null || inputArgs.size() == 0) {
return;
}
if (params == null) {
params = new HashMap<>();
}
for (McpArgDto arg : inputArgs) {
Object val = params.get(arg.getName());
if (val == null) {
val = StringUtils.isNotBlank(arg.getBindValue()) ? arg.getBindValue() : null;
if (val == null && arg.isRequire()) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, arg.getName());
}
}
if (arg.getDataType() == McpDataTypeEnum.Object) {
if (val == null) {
params.put(arg.getName(), new HashMap<>());
}
checkAndUpdateParams(arg.getSubArgs(), (Map<String, Object>) params.get(arg.getName()));
}
if (arg.getDataType() == McpDataTypeEnum.Array_Object) {
if (val == null) {
params.put(arg.getName(), new ArrayList<>());
}
checkAndUpdateListParams(arg, val);
}
if (arg.getDataType() == McpDataTypeEnum.Array_Boolean) {
List<Boolean> newVals = new ArrayList<>();
if (val != null) {
if (val instanceof List) {
for (Object v : (List<?>) val) {
newVals.add(Boolean.parseBoolean(v.toString()));
}
}
}
params.put(arg.getName(), newVals);
}
if (arg.getDataType() == McpDataTypeEnum.Array_Integer) {
List<Integer> newVals = new ArrayList<>();
if (val != null) {
if (val instanceof List) {
for (Object v : (List<?>) val) {
newVals.add(Integer.parseInt(v.toString()));
}
}
}
params.put(arg.getName(), newVals);
}
if (arg.getDataType() == McpDataTypeEnum.Array_Number) {
List<Number> newVals = new ArrayList<>();
if (val != null) {
if (val instanceof List) {
for (Object v : (List<?>) val) {
newVals.add(Double.parseDouble(v.toString()));
}
}
}
params.put(arg.getName(), newVals);
}
if (arg.getDataType() == McpDataTypeEnum.Boolean) {
if (val != null) {
params.put(arg.getName(), Boolean.parseBoolean(val.toString()));
}
}
if (arg.getDataType() == McpDataTypeEnum.Integer) {
if (val != null) {
params.put(arg.getName(), Integer.parseInt(val.toString()));
}
}
if (arg.getDataType() == McpDataTypeEnum.Number) {
if (val != null) {
params.put(arg.getName(), Double.parseDouble(val.toString()));
}
}
if (arg.getDataType() == McpDataTypeEnum.String) {
if (val != null) {
params.put(arg.getName(), val.toString());
}
}
}
}
private void checkAndUpdateListParams(McpArgDto arg, Object val) {
if (!(val instanceof List)) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.paramArgTypeInvalid, arg.getName());
}
for (Object obj : (List<?>) val) {
if (obj instanceof Map) {
checkAndUpdateParams(arg.getSubArgs(), (Map<String, Object>) obj);
} else {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.paramArgTypeInvalid, arg.getName());
}
}
}
private Disposable getPrompt(Sinks.Many<McpExecuteOutput> sink, McpAsyncClientWrapper
mcpAsyncClientWrapper, McpExecuteRequest mcpExecuteRequestDto) {
McpSchema.GetPromptRequest getPromptRequest = new McpSchema.GetPromptRequest(mcpExecuteRequestDto.getName(), mcpExecuteRequestDto.getParams());
return mcpAsyncClientWrapper.getClient().getPrompt(getPromptRequest).doOnSuccess(getPromptResult -> {
try {
List<McpContent> mcpContents = getPromptResult.messages().stream().map(content -> {
McpPromptContent mcpPromptContent = new McpPromptContent();
mcpPromptContent.setRole(content.role().name());
mcpPromptContent.setContent(convertToMcpContent(content.content()));
return mcpPromptContent;
}).collect(Collectors.toList());
sink.tryEmitNext(McpExecuteOutput.builder()
.success(true).message(null)
.result(mcpContents).build());
sink.tryEmitComplete();
} finally {
returnMcpClient(mcpExecuteRequestDto, mcpAsyncClientWrapper);
}
}).doOnError(throwable -> {
log.error("callTool error", throwable);
sink.tryEmitError(throwable);
mcpDeployRpcService.closeMcpClient(mcpExecuteRequestDto.getMcpDto().getId().toString(), mcpExecuteRequestDto.getSessionId(), mcpAsyncClientWrapper);
}).subscribe();
}
private Disposable readResource(Sinks.Many<McpExecuteOutput> sink, McpAsyncClientWrapper
mcpAsyncClientWrapper, McpExecuteRequest mcpExecuteRequestDto) {
McpSchema.ReadResourceRequest readResourceRequest = new McpSchema.ReadResourceRequest(mcpExecuteRequestDto.getParams().get("uri").toString());
return mcpAsyncClientWrapper.getClient().readResource(readResourceRequest).doOnSuccess((resourceResult) -> {
try {
List<McpContent> mcpContents = resourceResult.contents().stream().map(content -> {
McpResourceContent mcpResourceContent = new McpResourceContent();
mcpResourceContent.setUri(content.uri());
mcpResourceContent.setMimeType(content.mimeType());
if (content instanceof McpSchema.TextResourceContents) {
mcpResourceContent.setData(((McpSchema.TextResourceContents) content).text());
}
if (content instanceof McpSchema.BlobResourceContents) {
mcpResourceContent.setBlob(((McpSchema.BlobResourceContents) content).blob());
}
return mcpResourceContent;
}).collect(Collectors.toList());
sink.tryEmitNext(McpExecuteOutput.builder()
.success(true).message(null)
.result(mcpContents).build());
sink.tryEmitComplete();
} finally {
returnMcpClient(mcpExecuteRequestDto, mcpAsyncClientWrapper);
}
}).doOnError(throwable -> {
log.error("callTool error", throwable);
sink.tryEmitError(throwable);
mcpDeployRpcService.closeMcpClient(mcpExecuteRequestDto.getMcpDto().getId().toString(), mcpExecuteRequestDto.getSessionId(), mcpAsyncClientWrapper);
}).subscribe();
}
private Disposable callTool(Sinks.Many<McpExecuteOutput> sink, McpAsyncClientWrapper
mcpAsyncClientWrapper, McpExecuteRequest mcpExecuteRequestDto) {
log.debug("callTool, getMcpDto {}", mcpExecuteRequestDto.getMcpDto());
McpSchema.CallToolRequest callToolRequest = new McpSchema.CallToolRequest(mcpExecuteRequestDto.getName(), JSON.toJSONString(mcpExecuteRequestDto.getParams()));
return mcpAsyncClientWrapper.getClient().callTool(callToolRequest).onErrorResume(throwable -> {
log.error("callTool error", throwable);
return Mono.error(throwable);
}).doOnSuccess(callToolResult -> {
try {
List<McpContent> mcpContents = callToolResult.content().stream().map((ct) -> convertToMcpContent(ct)).collect(Collectors.toList());
boolean isSuccess = callToolResult.isError() == null || !callToolResult.isError();
String message = null;
if (!isSuccess) {
message = JSON.toJSONString(mcpContents);
}
sink.tryEmitNext(McpExecuteOutput.builder()
.success(isSuccess)
.message(message)
.result(mcpContents).build());
sink.tryEmitComplete();
} finally {
returnMcpClient(mcpExecuteRequestDto, mcpAsyncClientWrapper);
}
}).doOnError(throwable -> {
log.error("callTool error", throwable);
sink.tryEmitError(throwable);
mcpDeployRpcService.closeMcpClient(mcpExecuteRequestDto.getMcpDto().getId().toString(), mcpExecuteRequestDto.getSessionId(), mcpAsyncClientWrapper);
}).subscribe();
}
private void returnMcpClient(McpExecuteRequest mcpExecuteRequestDto, McpAsyncClientWrapper
mcpAsyncClientWrapper) {
if (mcpExecuteRequestDto.isKeepAlive()) {
mcpDeployRpcService.returnMcpClient(mcpExecuteRequestDto.getMcpDto().getId().toString(), mcpExecuteRequestDto.getSessionId(), mcpAsyncClientWrapper);
} else {
mcpDeployRpcService.closeMcpClient(mcpExecuteRequestDto.getMcpDto().getId().toString(), mcpExecuteRequestDto.getSessionId(), mcpAsyncClientWrapper);
}
}
private McpContent convertToMcpContent(McpSchema.Content ct) {
if (ct.type().equals("text")) {
McpSchema.TextContent textContent = (McpSchema.TextContent) ct;
McpTextContent mcpTextContent = new McpTextContent();
mcpTextContent.setData(textContent.text());
mcpTextContent.setType(McpContentTypeEnum.TEXT);
return mcpTextContent;
}
if (ct.type().equals("image")) {
McpSchema.ImageContent imageContent = (McpSchema.ImageContent) ct;
McpImageContent mcpImageContent = new McpImageContent();
mcpImageContent.setAudience(imageContent.audience().stream().map(role -> role.name()).collect(Collectors.toList()));
mcpImageContent.setData(imageContent.data());
mcpImageContent.setMimeType(imageContent.mimeType());
mcpImageContent.setPriority(imageContent.priority());
return mcpImageContent;
}
if (ct.type().equals("resource")) {
McpSchema.EmbeddedResource embeddedResource = (McpSchema.EmbeddedResource) ct;
McpResourceContent mcpResourceContent = new McpResourceContent();
mcpResourceContent.setUri(embeddedResource.resource().uri());
mcpResourceContent.setMimeType(embeddedResource.resource().mimeType());
if (embeddedResource.resource() instanceof McpSchema.TextResourceContents) {
mcpResourceContent.setData(((McpSchema.TextResourceContents) embeddedResource.resource()).text());
}
if (embeddedResource.resource() instanceof McpSchema.BlobResourceContents) {
mcpResourceContent.setBlob(((McpSchema.BlobResourceContents) embeddedResource.resource()).blob());
}
mcpResourceContent.setMimeType(embeddedResource.resource().mimeType());
return mcpResourceContent;
}
return new McpContent();
}
private Flux<McpExecuteOutput> searchKnowledge(McpExecuteRequest mcpExecuteRequestDto, McpComponentDto component, Map<String, Object> params) {
if (params.get("query") == null || params.get("query").equals("")) {
return Flux.just(new McpExecuteOutput(false, "查询条件缺失", null));
}
KnowledgeSearchRequest request = new KnowledgeSearchRequest();
request.setQuery(params.get("query").toString());
if (params.get("topK") != null) {
try {
request.setMaxRecallCount(Integer.parseInt(params.get("topK").toString()));
} catch (NumberFormatException e) {
// 忽略
request.setMaxRecallCount(5);
}
} else {
request.setMaxRecallCount(5);
}
request.setKnowledgeBaseIds(List.of(component.getTargetId()));
request.setUser(mcpExecuteRequestDto.getUser());
request.setSearchStrategy(SearchStrategyEnum.MIXED);
request.setMatchingDegree(0.5);
request.setRequestId(mcpExecuteRequestDto.getRequestId());
return Flux.create(emitter -> iAgentRpcService.searchKnowledge(request).subscribe(
knowledgeQaDtos -> {
List<McpContent> mcpContents = knowledgeQaDtos.stream().map(qa -> {
McpTextContent mcpTextContent = new McpTextContent();
mcpTextContent.setData(JSON.toJSONString(qa));
mcpTextContent.setType(McpContentTypeEnum.TEXT);
return mcpTextContent;
}).collect(Collectors.toList());
McpExecuteOutput mcpExecuteOutput = McpExecuteOutput.builder()
.success(true)
.result(mcpContents)
.build();
emitter.next(mcpExecuteOutput);
emitter.complete();
},
throwable -> {
McpExecuteOutput mcpExecuteOutput = McpExecuteOutput.builder()
.success(false)
.message(throwable.getMessage())
.build();
emitter.next(mcpExecuteOutput);
emitter.complete();
}
));
}
private Flux<McpExecuteOutput> executeTable(McpComponentDto component, McpExecuteRequest mcpExecuteRequestDto) {
Object sql = mcpExecuteRequestDto.getParams().get("sql");
if (sql == null || StringUtils.isBlank(sql.toString())) {
return Flux.just(new McpExecuteOutput(false, "sql参数缺失", null));
}
DorisTableDataRequest request = new DorisTableDataRequest(component.getTargetId(), sql.toString(), new HashMap<>(), new HashMap<>());
DorisTableDataResponse dorisTableDataResponse = iComposeDbTableRpcService.queryTableData(request);
Map<String, Object> data = new HashMap<>();
data.put("id", dorisTableDataResponse.getRowId());
data.put("outputList", dorisTableDataResponse.getData());
data.put("rowNum", dorisTableDataResponse.getRowNum() == null ? 0 : dorisTableDataResponse.getRowNum().intValue());
if (dorisTableDataResponse.getRowId() != null) {
UserDto userDto = (UserDto) mcpExecuteRequestDto.getUser();
if (userDto != null) {
StringBuilder updateSqlBuilder = new StringBuilder();
updateSqlBuilder.append("UPDATE ").append(dorisTableDataResponse.getTableDefineVo().getTableName()).append(" SET ");
if (userDto.getUserName() != null) {
updateSqlBuilder.append("user_name = '").append(userDto.getUserName()).append("',");
}
if (userDto.getNickName() != null) {
updateSqlBuilder.append("nick_name = '").append(userDto.getNickName()).append("',");
}
updateSqlBuilder.append("uid = '").append(userDto.getUid()).append("'");
updateSqlBuilder.append(" WHERE id = ").append(dorisTableDataResponse.getRowId());
DorisTableDataRequest updateRequest = new DorisTableDataRequest(component.getTargetId(), updateSqlBuilder.toString(), new HashMap<>(), new HashMap<>());
iComposeDbTableRpcService.queryTableData(updateRequest);
}
}
McpTextContent mcpTextContent = new McpTextContent();
mcpTextContent.setData(JSON.toJSONString(data));
mcpTextContent.setType(McpContentTypeEnum.TEXT);
McpExecuteOutput mcpExecuteOutput = McpExecuteOutput.builder()
.success(true)
.result(List.of(mcpTextContent))
.build();
return Flux.just(mcpExecuteOutput);
}
private Flux<McpExecuteOutput> executeWorkflow(McpDto mcpDto, McpComponentDto component, McpExecuteRequest
mcpExecuteRequestDto) {
WorkflowExecuteRequestDto workflowExecuteRequest = new WorkflowExecuteRequestDto();
workflowExecuteRequest.setWorkflowId(component.getTargetId());
workflowExecuteRequest.setSpaceId(mcpDto.getSpaceId());
workflowExecuteRequest.setParams(mcpExecuteRequestDto.getParams());
workflowExecuteRequest.setConversationId(mcpExecuteRequestDto.getSessionId());
workflowExecuteRequest.setRequestId(mcpExecuteRequestDto.getRequestId());
workflowExecuteRequest.setUser(mcpExecuteRequestDto.getUser());
workflowExecuteRequest.setConfig(component.getTargetConfig());
workflowExecuteRequest.setBindConfig(component.getTargetBindConfig());
workflowExecuteRequest.setTraceContext(mcpExecuteRequestDto.getTraceContext());
TenantConfigDto tenantConfig = (TenantConfigDto) RequestContext.get().getTenantConfig();
if (tenantConfig != null && tenantConfig.getEnableSubscription() != null && tenantConfig.getEnableSubscription() == 1) {
List<PriceEstimate.EstimateTarget> estimateTargets = List.of(PriceEstimate.EstimateTarget.builder().targetType(TargetTypeEnum.WORKFLOW).targetId(component.getTargetId().toString()).build());
PriceEstimate priceEstimate = iPricingRpcService.estimatePrice(mcpExecuteRequestDto.getTraceContext().getTenantId(), mcpExecuteRequestDto.getTraceContext().getBillUserId(), estimateTargets);
if (priceEstimate != null && !priceEstimate.isPass()) {
return Flux.just(McpExecuteOutput.builder()
.success(false)
.message(priceEstimate.getMessage())
.build());
}
}
return iAgentRpcService.executeWorkflow(workflowExecuteRequest)
.<McpExecuteOutput>map(result -> {
if (result.getType() == WfExecuteResultTypeEnum.EXECUTE_RESULT) {
String content;
if (StringUtils.isNotBlank(result.getOutputContent())) {
content = result.getOutputContent();
} else {
content = JSON.toJSONString(result.getData());
}
McpTextContent mcpTextContent = new McpTextContent();
mcpTextContent.setData(content);
mcpTextContent.setType(McpContentTypeEnum.TEXT);
return McpExecuteOutput.builder()
.success(true)
.result(List.of(mcpTextContent))
.build();
} else if (result.getType() == WfExecuteResultTypeEnum.EXECUTING_LOG) {
McpLogContent mcpLogContent = new McpLogContent();
mcpLogContent.setData(JSON.toJSONString(result.getData()));
mcpLogContent.setType(McpContentTypeEnum.TEXT);
return McpExecuteOutput.builder()
.success(true)
.result(List.of(mcpLogContent))
.build();
}
return null;
})
.filter(Objects::nonNull)
.onErrorResume(throwable -> Flux.just(McpExecuteOutput.builder()
.success(false)
.message(throwable.getMessage()).build()));
}
private Flux<McpExecuteOutput> executeAgent(McpComponentDto component, McpExecuteRequest
mcpExecuteRequestDto) {
if (mcpExecuteRequestDto.getParams() == null || mcpExecuteRequestDto.getParams().get("message") == null) {
return Flux.just(McpExecuteOutput.builder()
.success(false)
.message("请输入要执行的内容")
.build());
}
AgentExecuteRequestDto agentExecuteRequestDto = new AgentExecuteRequestDto();
if (mcpExecuteRequestDto.getParams().get("variables") != null && mcpExecuteRequestDto.getParams().get("variables") instanceof Map<?, ?>) {
agentExecuteRequestDto.setVariables((Map<String, Object>) mcpExecuteRequestDto.getParams().get("variables"));
}
agentExecuteRequestDto.setAgentId(component.getTargetId());
agentExecuteRequestDto.setMessage(mcpExecuteRequestDto.getParams().get("message").toString());
agentExecuteRequestDto.setUser(mcpExecuteRequestDto.getUser());
agentExecuteRequestDto.setSessionId(mcpExecuteRequestDto.getSessionId());
agentExecuteRequestDto.setTraceContext(mcpExecuteRequestDto.getTraceContext());
return Flux.create(sink -> iAgentRpcService.executeAgent(agentExecuteRequestDto)
.onErrorResume(throwable -> Mono.error(throwable))
.doOnNext(result -> {
if ("FINAL_RESULT".equals(result.getEventType())) {
String content;
if (StringUtils.isNotBlank(result.getData())) {
content = result.getData();
} else {
content = JSON.toJSONString(result.getData());
}
McpTextContent mcpTextContent = new McpTextContent();
mcpTextContent.setData(content);
mcpTextContent.setType(McpContentTypeEnum.TEXT);
McpExecuteOutput mcpExecuteOutput = McpExecuteOutput.builder()
.success(!result.isError())
.result(List.of(mcpTextContent))
.build();
sink.next(mcpExecuteOutput);
} else if ("MESSAGE".equals(result.getEventType())) {
McpLogContent mcpLogContent = new McpLogContent();
mcpLogContent.setData(JSON.toJSONString(result.getData()));
mcpLogContent.setType(McpContentTypeEnum.TEXT);
McpExecuteOutput mcpExecuteOutput = McpExecuteOutput.builder()
.success(true)
.result(List.of(mcpLogContent))
.build();
sink.next(mcpExecuteOutput);
}
}).doOnError(throwable -> {
McpExecuteOutput mcpExecuteOutput = McpExecuteOutput.builder()
.success(false)
.message(throwable.getMessage()).build();
sink.next(mcpExecuteOutput);
sink.complete();
}).doOnComplete(sink::complete).subscribe());
}
private Flux<McpExecuteOutput> executePlugin(McpDto mcpDto, McpComponentDto component, McpExecuteRequest mcpExecuteRequestDt) {
PluginExecuteRequestDto pluginExecuteRequest = new PluginExecuteRequestDto();
pluginExecuteRequest.setSpaceId(mcpDto.getSpaceId());
pluginExecuteRequest.setParams(mcpExecuteRequestDt.getParams());
pluginExecuteRequest.setConfig(component.getTargetConfig());
pluginExecuteRequest.setBindConfig(component.getTargetBindConfig());
pluginExecuteRequest.setUser(mcpExecuteRequestDt.getUser());
pluginExecuteRequest.setTraceContext(mcpExecuteRequestDt.getTraceContext());
TenantConfigDto tenantConfig = (TenantConfigDto) RequestContext.get().getTenantConfig();
if (tenantConfig != null && tenantConfig.getEnableSubscription() != null && tenantConfig.getEnableSubscription() == 1) {
List<PriceEstimate.EstimateTarget> estimateTargets = List.of(PriceEstimate.EstimateTarget.builder().targetType(TargetTypeEnum.PLUGIN).targetId(component.getTargetId().toString()).build());
PriceEstimate priceEstimate = iPricingRpcService.estimatePrice(mcpExecuteRequestDt.getTraceContext().getTenantId(), mcpExecuteRequestDt.getTraceContext().getBillUserId(), estimateTargets);
if (priceEstimate != null && !priceEstimate.isPass()) {
return Flux.just(McpExecuteOutput.builder()
.success(false)
.message(priceEstimate.getMessage())
.build());
}
}
return iAgentRpcService.executePlugin(pluginExecuteRequest)
.timeout(Duration.ofSeconds(180))
.onErrorResume(throwable -> {
log.warn("executePlugin error", throwable);
if (throwable instanceof TimeoutException) {
return Mono.error(new TimeoutException("executePlugin timeout"));
}
return Mono.error(throwable);
})
.<McpExecuteOutput>map(pluginExecuteResult -> {
McpTextContent mcpTextContent = new McpTextContent();
mcpTextContent.setData(JSON.toJSONString(pluginExecuteResult));
mcpTextContent.setType(McpContentTypeEnum.TEXT);
return McpExecuteOutput.builder()
.success(true)
.result(List.of(mcpTextContent))
.build();
})
.onErrorResume(error -> {
log.warn("executePlugin failed", error);
return Mono.just(new McpExecuteOutput(false, error.getMessage(), null));
})
.flux();
}
@Override
public Long addAndDeployMcp(Long userId, Long spaceId, McpDto mcpDto) {
mcpDto.setCreatorId(userId);
mcpDto.setSpaceId(spaceId);
Date now = new Date();
mcpDto.setModified(now);
mcpDto.setDeployed(now);
mcpConfigApplicationService.addMcp(mcpDto);
return mcpDto.getId();
}
@Override
public Long deployOfficialMcp(McpDto mcpDto) {
return mcpConfigApplicationService.deployOfficialMcp(mcpDto);
}
@Override
public void stopOfficialMcp(Long id) {
mcpConfigApplicationService.stopOfficialMcp(id);
}
@Override
public Long deployProxyMcp(McpDto mcpDto) {
return mcpConfigApplicationService.deployProxyMcp(mcpDto);
}
@Override
public String getExportMcpServerConfig(Long userId, Long mcpId, UserAccessKeyDto.UserAccessKeyConfig userAccessKeyConfig) {
return mcpConfigApplicationService.getExportMcpServerConfig(userId, mcpId, userAccessKeyConfig);
}
@Override
public Long countTotalMcps() {
return mcpConfigApplicationService.countTotalMcps();
}
@Override
public com.baomidou.mybatisplus.core.metadata.IPage<McpDto> queryListForManage(Integer pageNo, Integer pageSize, String name, java.util.List<Long> creatorIds, Long spaceId) {
com.xspaceagi.mcp.adapter.dto.McpPageQueryDto queryDto = new com.xspaceagi.mcp.adapter.dto.McpPageQueryDto();
queryDto.setPage(pageNo);
queryDto.setPageSize(pageSize);
queryDto.setSpaceId(spaceId);
queryDto.setKw(name);
queryDto.setCreatorIds(creatorIds);
return mcpConfigApplicationService.queryDeployedMcpListForManage(queryDto);
}
@Override
public void deleteForManage(Long id) {
mcpConfigApplicationService.deleteMcp(id);
}
}

View File

@@ -0,0 +1,23 @@
package com.xspaceagi.mcp.ui.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xspaceagi.mcp.infra.server.WebMvcSseServerTransportProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerResponse;
@Configuration
public class McpConfig implements WebMvcConfigurer {
@Bean
public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider(ObjectMapper mapper) {
return new WebMvcSseServerTransportProvider(mapper, "/api/mcp/message", "/api/mcp/sse");
}
@Bean
public RouterFunction<ServerResponse> mcpRouterFunction(WebMvcSseServerTransportProvider transportProvider) {
return transportProvider.getRouterFunction();
}
}

View File

@@ -0,0 +1,400 @@
package com.xspaceagi.mcp.ui.web.controller;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.xspaceagi.agent.core.adapter.application.ModelApplicationService;
import com.xspaceagi.mcp.adapter.application.McpConfigApplicationService;
import com.xspaceagi.mcp.adapter.application.McpDeployTaskService;
import com.xspaceagi.mcp.adapter.dto.McpPageQueryDto;
import com.xspaceagi.mcp.sdk.IMcpApiService;
import com.xspaceagi.mcp.sdk.dto.*;
import com.xspaceagi.mcp.sdk.enums.DeployStatusEnum;
import com.xspaceagi.mcp.sdk.enums.InstallTypeEnum;
import com.xspaceagi.mcp.spec.utils.UrlExtractUtil;
import com.xspaceagi.mcp.ui.web.controller.dto.EnNameDto;
import com.xspaceagi.mcp.ui.web.controller.dto.McpCreateDto;
import com.xspaceagi.mcp.ui.web.controller.dto.McpTestDto;
import com.xspaceagi.mcp.ui.web.controller.dto.McpUpdateDto;
import com.xspaceagi.system.application.dto.SpaceUserDto;
import com.xspaceagi.system.application.dto.TenantConfigDto;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.application.service.SpaceApplicationService;
import com.xspaceagi.system.infra.dao.entity.SpaceUser;
import com.xspaceagi.system.infra.dao.entity.User;
import com.xspaceagi.system.sdk.permission.SpacePermissionService;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.dto.ReqResult;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.enums.YesOrNoEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static com.xspaceagi.system.spec.enums.ResourceEnum.*;
@Tag(name = "MCP相关接口")
@RestController
@RequestMapping("/api/mcp")
public class McpController {
@Resource
private SpacePermissionService spacePermissionService;
@Resource
private SpaceApplicationService spaceApplicationService;
@Resource
private McpConfigApplicationService mcpConfigApplicationService;
@Resource
private McpDeployTaskService mcpDeployTaskService;
@Resource
private IMcpApiService iMcpApiService;
@Resource
private ModelApplicationService modelApplicationService;
@RequireResource(MCP_CREATE)
@Operation(summary = "MCP服务创建")
@PostMapping("/create")
public ReqResult<McpDto> create(@RequestBody McpCreateDto mcpCreateDto) {
spacePermissionService.checkSpaceUserPermission(mcpCreateDto.getSpaceId());
Assert.notNull(mcpCreateDto.getInstallType(), "install type cannot be left blank.");
Assert.notNull(mcpCreateDto.getMcpConfig(), "MCP config cannot be left blank.");
checkServerConfig(mcpCreateDto.getInstallType(), mcpCreateDto.getMcpConfig());
McpDto mcpDto = new McpDto();
BeanUtils.copyProperties(mcpCreateDto, mcpDto);
if (containsChinese(mcpCreateDto.getName())) {
try {
EnNameDto enNameDto = modelApplicationService.call(mcpCreateDto.getName(), new ParameterizedTypeReference<EnNameDto>() {
});
mcpDto.setServerName(enNameDto.getEnName());
} catch (Exception e) {
}
} else {
mcpDto.setServerName(mcpCreateDto.getName());
}
mcpDto.setCreatorId(RequestContext.get().getUserId());
mcpConfigApplicationService.addMcp(mcpDto);
if (mcpCreateDto.isWithDeploy()) {
mcpDeployTaskService.addDeployTask(mcpDto);
}
McpDto mcp = mcpConfigApplicationService.getMcp(mcpDto.getId());
clearMcpComponentConfig(mcp);
return ReqResult.success(mcp);
}
@RequireResource(MCP_SAVE)
@Operation(summary = "MCP服务更新")
@PostMapping("/update")
public ReqResult<McpDto> update(@RequestBody McpUpdateDto mcpUpdateDto) {
Assert.notNull(mcpUpdateDto.getId(), "MCP ID cannot be left blank.");
McpDto mcp = mcpConfigApplicationService.getMcp(mcpUpdateDto.getId());
if (mcp == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.mcpNotFound);
}
checkServerConfig(mcp.getInstallType(), mcpUpdateDto.getMcpConfig());
spacePermissionService.checkSpaceUserPermission(mcp.getSpaceId());
if (!mcp.getCreatorId().equals(RequestContext.get().getUserId())) {
spacePermissionService.checkSpaceAdminPermission(mcp.getSpaceId());
}
McpDto mcpDto = new McpDto();
BeanUtils.copyProperties(mcpUpdateDto, mcpDto);
//判断mcp.getName()是否含有中文
if (mcpUpdateDto.getName() != null && !mcpUpdateDto.getName().equals(mcp.getName()) && containsChinese(mcpUpdateDto.getName())) {
try {
EnNameDto enNameDto = modelApplicationService.call(mcpUpdateDto.getName(), new ParameterizedTypeReference<EnNameDto>() {
});
mcpDto.setServerName(enNameDto.getEnName());
} catch (Exception e) {
}
} else {
mcpDto.setServerName(mcpUpdateDto.getName());
}
mcpConfigApplicationService.updateMcp(mcpDto);
McpDto mcp1 = mcpConfigApplicationService.getMcp(mcpDto.getId());
if (mcpUpdateDto.isWithDeploy()) {
mcpDeployTaskService.addDeployTask(mcp1);
}
clearMcpComponentConfig(mcp1);
return ReqResult.success(mcp1);
}
private static boolean containsChinese(String str) {
Pattern pattern = Pattern.compile("[\\u4e00-\\u9fa5]");
Matcher matcher = pattern.matcher(str);
return matcher.find();
}
private void checkServerConfig(InstallTypeEnum installType, McpConfigDto mcpConfig) {
if (installType == InstallTypeEnum.COMPONENT || mcpConfig == null || mcpConfig.getServerConfig() == null) {
return;
}
if (!JSON.isValid(mcpConfig.getServerConfig())) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.mcpServiceConfigJsonInvalid);
}
JSONObject serverConfig = JSONObject.parseObject(mcpConfig.getServerConfig());
if (serverConfig == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.mcpServiceConfigJsonInvalid);
}
if (installType == InstallTypeEnum.NPX) {
if (!serverConfig.toJSONString().toLowerCase().contains("\"command\":\"npx\"")) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.mcpNpxConfigInvalid);
}
}
if (installType == InstallTypeEnum.UVX) {
if (!serverConfig.toJSONString().toLowerCase().contains("\"command\":\"uvx\"")) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.mcpUvxConfigInvalid);
}
}
if (installType == InstallTypeEnum.STREAMABLE_HTTP) {
List<String> list = UrlExtractUtil.extractUrls(mcpConfig.getServerConfig());
if (list.size() == 0) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.mcpStreamableHttpConfigInvalid);
}
}
if (installType == InstallTypeEnum.SSE) {
List<String> list = UrlExtractUtil.extractUrls(mcpConfig.getServerConfig());
if (list.size() == 0) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.mcpSseConfigInvalid);
}
}
}
@RequireResource(MCP_QUERY_DETAIL)
@Operation(summary = "MCP详情查询")
@GetMapping("/{id}")
public ReqResult<McpDto> getOne(@PathVariable Long id) {
McpDto mcp = mcpConfigApplicationService.getMcp(id);
if (mcp == null) {
return ReqResult.error("MCP does not exist");
}
spacePermissionService.checkSpaceUserPermission(mcp.getSpaceId());
SpaceUserDto spaceUserDto = spaceApplicationService.querySpaceUser(mcp.getSpaceId(), RequestContext.get().getUserId());
mcp.setPermissions(generatePermissionList(spaceUserDto, mcp).stream().map(permission -> permission.name()).collect(Collectors.toList()));
clearMcpComponentConfig(mcp);
return ReqResult.success(mcp);
}
private void clearMcpComponentConfig(McpDto mcp) {
if (mcp.getMcpConfig() != null && CollectionUtils.isNotEmpty(mcp.getMcpConfig().getComponents())) {
List<McpComponentDto> components = mcp.getMcpConfig().getComponents();
for (McpComponentDto component : components) {
component.setTargetConfig(null);
}
}
}
@RequireResource(MCP_QUERY_LIST)
@Operation(summary = "MCP管理列表")
@GetMapping("/list/{spaceId}")
public ReqResult<List<McpDto>> list(@PathVariable Long spaceId) {
spacePermissionService.checkSpaceUserPermission(spaceId);
SpaceUserDto spaceUserDto = spaceApplicationService.querySpaceUser(spaceId, RequestContext.get().getUserId());
List<McpDto> mcpDtos = mcpConfigApplicationService.queryMcpListBySpaceId(spaceId);
mcpDtos.forEach(mcpDto -> {
List<SpaceObjectPermissionEnum> spaceObjectPermissionEnums = generatePermissionList(spaceUserDto, mcpDto);
mcpDto.setPermissions(spaceObjectPermissionEnums.stream().map(SpaceObjectPermissionEnum::name).collect(Collectors.toList()));
});
return ReqResult.success(mcpDtos);
}
@RequireResource(MCP_QUERY_LIST)
@Operation(summary = "MCP列表官方服务")
@GetMapping("/official/list")
public ReqResult<List<McpDto>> officialList() {
List<McpDto> mcpDtos = mcpConfigApplicationService.queryMcpListBySpaceId(-1L);
//移除未发布的
mcpDtos.removeIf(mcpDto -> mcpDto.getDeployStatus() != DeployStatusEnum.Deployed);
List<SpaceObjectPermissionEnum> permissionList = new ArrayList<>();
mcpDtos.forEach(mcpDto -> mcpDto.setPermissions(permissionList.stream().map(SpaceObjectPermissionEnum::name).collect(Collectors.toList())));
return ReqResult.success(mcpDtos);
}
@RequireResource(MCP_DELETE)
@Operation(summary = "MCP删除")
@PostMapping("/delete/{id}")
public ReqResult<Void> delete(@PathVariable Long id) {
McpDto mcp = mcpConfigApplicationService.getMcp(id);
if (mcp == null) {
return ReqResult.error("MCP does not exist");
}
spacePermissionService.checkSpaceUserPermission(mcp.getSpaceId());
if (!mcp.getCreatorId().equals(RequestContext.get().getUserId())) {
spacePermissionService.checkSpaceAdminPermission(mcp.getSpaceId());
}
mcpConfigApplicationService.deleteMcp(id);
return ReqResult.success();
}
@RequireResource(MCP_STOP)
@Operation(summary = "MCP停用")
@PostMapping("/stop/{id}")
public ReqResult<Void> stop(@PathVariable Long id) {
McpDto mcp = mcpConfigApplicationService.getMcp(id);
if (mcp == null) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.mcpNotFound);
}
spacePermissionService.checkSpaceUserPermission(mcp.getSpaceId());
if (!mcp.getCreatorId().equals(RequestContext.get().getUserId())) {
spacePermissionService.checkSpaceAdminPermission(mcp.getSpaceId());
}
McpDto update = new McpDto();
update.setId(mcp.getId());
update.setDeployStatus(DeployStatusEnum.Stopped);
mcpConfigApplicationService.updateMcp(update);
return ReqResult.success();
}
@RequireResource(MCP_EXPORT)
@Operation(summary = "MCP服务导出")
@PostMapping("/server/config/export/{id}")
public ReqResult<String> export(@PathVariable Long id) {
McpDto mcp = mcpConfigApplicationService.getDeployedMcp(id);
if (mcp == null) {
return ReqResult.error("MCP does not exist or deployment not completed");
}
TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig();
boolean allowMcpExport = tenantConfigDto.getAllowMcpExport() == null || tenantConfigDto.getAllowMcpExport().equals(YesOrNoEnum.Y.getKey());
UserDto userDto = (UserDto) RequestContext.get().getUser();
if (!allowMcpExport && userDto.getRole() != User.Role.Admin) {
return ReqResult.error("MCP export is currently not allowed");
}
spacePermissionService.checkSpaceUserPermission(mcp.getSpaceId());
return ReqResult.success(mcpConfigApplicationService.getExportMcpServerConfig(RequestContext.get().getUserId(), id, null));
}
@RequireResource(MCP_EXPORT)
@Operation(summary = "MCP服务重新生成配置")
@PostMapping("/server/config/refresh/{id}")
public ReqResult<String> exportRefresh(@PathVariable Long id) {
McpDto mcp = mcpConfigApplicationService.getDeployedMcp(id);
if (mcp == null) {
return ReqResult.error("MCP does not exist or deployment not completed");
}
spacePermissionService.checkSpaceUserPermission(mcp.getSpaceId());
return ReqResult.success(mcpConfigApplicationService.refreshExportMcpServerConfig(RequestContext.get().getUserId(), id));
}
@RequireResource(MCP_QUERY_LIST)
@Operation(summary = "MCP已发布服务列表弹框使用")
@GetMapping("/deployed/list/{spaceId}")
public ReqResult<List<McpDto>> deployedList(@PathVariable Long spaceId) {
spacePermissionService.checkSpaceUserPermission(spaceId);
SpaceUserDto spaceUserDto = spaceApplicationService.querySpaceUser(spaceId, RequestContext.get().getUserId());
McpPageQueryDto mcpPageQueryDto = new McpPageQueryDto();
mcpPageQueryDto.setSpaceId(spaceId);
mcpPageQueryDto.setPage(1);
mcpPageQueryDto.setPageSize(100);
List<McpDto> mcpDtos = mcpConfigApplicationService.queryDeployedMcpList(mcpPageQueryDto).getRecords();
mcpDtos.forEach(mcpDto -> {
List<SpaceObjectPermissionEnum> spaceObjectPermissionEnums = generatePermissionList(spaceUserDto, mcpDto);
mcpDto.setPermissions(spaceObjectPermissionEnums.stream().map(SpaceObjectPermissionEnum::name).collect(Collectors.toList()));
});
return ReqResult.success(mcpDtos);
}
@RequireResource(MCP_QUERY_LIST)
@Operation(summary = "MCP已发布服务列表弹框使用-新)")
@PostMapping("/deployed/list")
public ReqResult<IPage<McpDto>> deployedList(@RequestBody @Valid McpPageQueryDto mcpPageQueryDto) {
if (mcpPageQueryDto.getSpaceId() != null) {
spacePermissionService.checkSpaceUserPermission(mcpPageQueryDto.getSpaceId());
}
IPage<McpDto> mcpDtoIPage = mcpConfigApplicationService.queryDeployedMcpList(mcpPageQueryDto);
return ReqResult.success(mcpDtoIPage);
}
@RequireResource(MCP_QUERY_DETAIL)
@Operation(summary = "MCP试运行")
@PostMapping("/test")
public ReqResult<McpExecuteOutput> test(@RequestBody McpTestDto mcpTestDto) {
Assert.notNull(mcpTestDto, "mcpExecuteRequestDto must be non-null");
Assert.notNull(mcpTestDto.getId(), "id must be non-null");
McpDto mcp = mcpConfigApplicationService.getDeployedMcp(mcpTestDto.getId());
if (mcp == null) {
return ReqResult.error("MCP not deployed or disabled");
}
spacePermissionService.checkSpaceUserPermission(mcp.getSpaceId());
McpExecuteRequest mcpExecuteRequest = McpExecuteRequest.builder()
.sessionId(UUID.randomUUID().toString().replace("-", ""))
.user(RequestContext.get().getUser())
.mcpDto(mcp)
.executeType(mcpTestDto.getExecuteType())
.requestId(mcpTestDto.getRequestId())
.params(mcpTestDto.getParams())
.keepAlive(false)
.name(mcpTestDto.getName()).build();
McpExecuteOutput mcpExecuteOutput;
try {
mcpExecuteOutput = iMcpApiService.execute(mcpExecuteRequest).blockLast();
} catch (Exception e) {
return ReqResult.error(e.getMessage());
}
return ReqResult.success(mcpExecuteOutput);
}
public static List<SpaceObjectPermissionEnum> generatePermissionList(SpaceUserDto spaceUserDto, McpDto mcpDto) {
List<SpaceObjectPermissionEnum> permissionList = new ArrayList<>();
TenantConfigDto tenantConfigDto = (TenantConfigDto) RequestContext.get().getTenantConfig();
boolean allowMcpExport = tenantConfigDto.getAllowMcpExport() == null || tenantConfigDto.getAllowMcpExport().equals(YesOrNoEnum.Y.getKey());
if (mcpDto.isPlatformMcp()) {
allowMcpExport = false;//平台提供的MCP不允许导出
}
UserDto user = (UserDto) RequestContext.get().getUser();
if ((allowMcpExport || (!mcpDto.isPlatformMcp() && user != null && user.getRole() == User.Role.Admin)) && (mcpDto.getDeployStatus() == DeployStatusEnum.Deployed || mcpDto.getDeployStatus() == DeployStatusEnum.Deploying)) {
permissionList.add(SpaceObjectPermissionEnum.Export);
}
if (user != null && user.getRole() == User.Role.Admin) {
permissionList.add(SpaceObjectPermissionEnum.EditOrDeploy);
permissionList.add(SpaceObjectPermissionEnum.Delete);
if (mcpDto.getDeployStatus() == DeployStatusEnum.Deployed) {
permissionList.add(SpaceObjectPermissionEnum.Stop);
}
return permissionList;
}
if (spaceUserDto == null) {
permissionList.clear();
return permissionList;
}
if (spaceUserDto.getUserId().equals(mcpDto.getCreatorId()) || spaceUserDto.getRole() == SpaceUser.Role.Admin || spaceUserDto.getRole() == SpaceUser.Role.Owner) {
permissionList.add(SpaceObjectPermissionEnum.EditOrDeploy);
permissionList.add(SpaceObjectPermissionEnum.Delete);
if (mcpDto.getDeployStatus() == DeployStatusEnum.Deployed) {
permissionList.add(SpaceObjectPermissionEnum.Stop);
}
}
return permissionList;
}
public enum SpaceObjectPermissionEnum {
Delete,
EditOrDeploy,
Export,
Stop,
}
}

View File

@@ -0,0 +1,14 @@
package com.xspaceagi.mcp.ui.web.controller.dto;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import lombok.Data;
import java.io.Serializable;
@Data
public class EnNameDto implements Serializable {
@JsonPropertyDescription("根据用户输入生成一个英文名称如果输入本身不是中文则直接返回如果中文没有任何含义可以返回拼音。不超过32个字符不能出现空格使用小写多个单词用中横线隔开例如 time-mcp")
private String enName;
}

View File

@@ -0,0 +1,37 @@
package com.xspaceagi.mcp.ui.web.controller.dto;
import com.xspaceagi.mcp.sdk.dto.McpConfigDto;
import com.xspaceagi.mcp.sdk.enums.InstallTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class McpCreateDto {
@Schema(description = "空间ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private Long spaceId;
@Schema(description = "MCP名称", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private String name;
@Schema(description = "MCP描述", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private String description;
@Schema(description = "MCP图标")
private String icon;
@Schema(description = "MCP安装方式", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private InstallTypeEnum installType;
@Schema(description = "MCP配置", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private McpConfigDto mcpConfig;
@Schema(description = "是否部署")
private boolean withDeploy;
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.mcp.ui.web.controller.dto;
import com.xspaceagi.mcp.sdk.dto.McpExecuteRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Map;
@Data
public class McpTestDto {
private String requestId;
private Long id;
@Schema(description = "执行类型", requiredMode = Schema.RequiredMode.REQUIRED)
private McpExecuteRequest.ExecuteTypeEnum executeType;
@Schema(description = "MCP工具/资源/提示词名称", requiredMode = Schema.RequiredMode.REQUIRED)
private String name;
@Schema(description = "参数")
private Map<String, Object> params;
}

View File

@@ -0,0 +1,31 @@
package com.xspaceagi.mcp.ui.web.controller.dto;
import com.xspaceagi.mcp.sdk.dto.McpConfigDto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class McpUpdateDto {
@Schema(description = "MCP ID")
private Long id;
@Schema(description = "MCP名称")
@NotNull
private String name;
@Schema(description = "MCP描述")
@NotNull
private String description;
@Schema(description = "MCP图标")
private String icon;
@Schema(description = "MCP配置")
@NotNull
private McpConfigDto mcpConfig;
@Schema(description = "是否部署")
private boolean withDeploy;
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-modules</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-mcp</artifactId>
<packaging>pom</packaging>
<modules>
<module>app-platform-mcp-spec</module>
<module>app-platform-mcp-infra</module>
<module>app-platform-mcp-application</module>
<module>app-platform-mcp-domain</module>
<module>app-platform-mcp-ui</module>
<module>app-platform-mcp-adapter</module>
<module>app-platform-mcp-sdk</module>
</modules>
<dependencies>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>system-sdk</artifactId>
</dependency>
<dependency>
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-log-sdk</artifactId>
</dependency>
</dependencies>
</project>