chore: initialize qiming workspace repository
This commit is contained in:
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.xspaceagi.mcp.infra.rpc.enums;
|
||||
|
||||
public enum McpDeployStatusEnum {
|
||||
Ready, Pending, Error
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.xspaceagi.mcp.infra.rpc.enums;
|
||||
|
||||
public enum McpPersistentTypeEnum {
|
||||
Persistent, OneShot
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.xspaceagi.mcp.sdk.enums;
|
||||
|
||||
public enum DeployStatusEnum {
|
||||
//“部署中”、“已部署”、“部署失败”、“已停止”
|
||||
Initialization,
|
||||
Deploying,
|
||||
Deployed,
|
||||
DeployFailed,
|
||||
Stopped
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.xspaceagi.mcp.sdk.enums;
|
||||
|
||||
public enum InstallTypeEnum {
|
||||
NPX,
|
||||
UVX,
|
||||
SSE,
|
||||
STREAMABLE_HTTP,
|
||||
COMPONENT;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.xspaceagi.mcp.sdk.enums;
|
||||
|
||||
public enum McpComponentTypeEnum {
|
||||
Plugin, Workflow, Knowledge, Table, Agent
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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数组
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
32
qiming-backend/app-platform-modules/app-platform-mcp/pom.xml
Normal file
32
qiming-backend/app-platform-modules/app-platform-mcp/pom.xml
Normal 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>
|
||||
Reference in New Issue
Block a user