Solon v3.9.0

mcp - McpSkill Client 与 Server 设计参考

</> markdown
2026年1月26日 上午9:13:39

通过 MCP (Model Context Protocol) 协议,可以实现 AI 技能(Skill)分布式(或跨进程)部署,进而实现 Solon AI Remote Skills。McpSkillClient 作为代理实现 Skill 接口,而 McpSkillServer 负责将本地技能生命周期映射为远程调用端点。

设计提示

  • hide 标记:我们在管理工具元数据中添加了 hide:1 标记,确保这些系统级端点不会被泄露给 LLM,仅供客户端代理使用。
  • 三态过滤机制:getToolsName 的设计允许服务端根据用户权限(如 prompt.attr("user_role"))实现极其灵活的能力分发。

1、McpSkillClient (客户端代理)

McpSkillClient 是本地 Solon AI Skill 的“替身”,它通过网络协议与远程服务握手,并根据当前 Prompt 上下文实现感知。

import org.noear.snack4.Feature;
import org.noear.snack4.ONode;
import org.noear.solon.Utils;
import org.noear.solon.ai.chat.prompt.Prompt;
import org.noear.solon.ai.chat.skill.Skill;
import org.noear.solon.ai.chat.skill.SkillMetadata;
import org.noear.solon.ai.chat.tool.FunctionTool;
import org.noear.solon.lang.Preview;

import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * MCP 客户端技能代理
 * <p>
 * 职责:作为 MCP 客户端,将远程 MCP 服务的能力(工具、资源、指令)封装为本地 {@link Skill} 接口。
 * 特点:支持跨进程的能力调用,并通过 {@link Prompt} 上下文实现远程准入检查与动态指令获取。
 *
 * @author noear
 * @since 3.9.0
 */
@Preview("3.9.0")
public class McpSkillClient implements Skill {
    /**
     * MCP 客户端提供者,负责底层的通信协议(如 Stdio, SSE)
     */
    protected final McpClientProvider clientProvider;
    /**
     * 缓存的技能元信息
     */
    protected SkillMetadata metadata;

    public McpSkillClient(McpClientProvider clientProvider) {
        this.clientProvider = clientProvider;

        // 从远程加载静态元信息(通过预定义的 Resource URI)
        String metadataJson = clientProvider.readResourceAsText("skill://metadataMcp")
                .getContent();

        metadata = ONode.deserialize(metadataJson, SkillMetadata.class);
    }

    @Override
    public String name() {
        return metadata.getName();
    }

    @Override
    public String description() {
        return metadata.getDescription();
    }

    @Override
    public SkillMetadata metadata() {
        return metadata;
    }

    /**
     * 跨进程准入检查:请求远程服务端判断当前 Prompt 环境是否允许激活该技能
     */
    @Override
    public boolean isSupported(Prompt prompt) {
        String promptJson = ONode.serialize(prompt, Feature.Write_ClassName);

        String result = clientProvider.callToolAsText("isSupportedMcp",
                        Utils.asMap("promptJson", promptJson))
                .getContent();

        return "true".equals(result);
    }

    @Override
    public void onAttach(Prompt prompt) {
        String promptJson = ONode.serialize(prompt, Feature.Write_ClassName);

        clientProvider.callToolAsText("onAttachMcp",
                        Utils.asMap("promptJson", promptJson));
    }

    /**
     * 动态指令获取:从远程服务端获取针对当前上下文优化后的 System Message 指令
     */
    @Override
    public String getInstruction(Prompt prompt) {
        String promptJson = ONode.serialize(prompt, Feature.Write_ClassName);

        return clientProvider.callToolAsText("getInstructionMcp",
                        Utils.asMap("promptJson", promptJson))
                .getContent();
    }

    /**
     * 获取远程导出的工具流
     * <p>
     * 过滤策略:自动剔除标记为 "hide" 的管理类工具(即元数据同步工具),仅保留业务工具
     */
    protected Stream<FunctionTool> getToolsStream() {
        return clientProvider.getTools().stream()
                .filter(tool -> {
                    return tool.meta() == null || tool.meta().containsKey("hide") == false;
                });
    }

    @Override
    public Collection<FunctionTool> getTools(Prompt prompt) {
        String promptJson = ONode.serialize(prompt, Feature.Write_ClassName);

        String toolsNameJson = clientProvider.callToolAsText("getToolsMcp",
                        Utils.asMap("promptJson", promptJson))
                .getContent();

        List<String> toolsName = ONode.deserialize(toolsNameJson, List.class);

        if(toolsName == null){
            return getToolsStream().collect(Collectors.toList());
        } else if(toolsName.isEmpty()) {
            return Collections.EMPTY_LIST;
        } else {
            return getToolsStream().filter(tool -> toolsName.contains(tool.name()))
                    .collect(Collectors.toList());
        }
    }
}

2、McpSkillServer (服务端基类)

McpSkillServer 将 Skill 生命周期方法映射为标准的 MCP 端点(Tools & Resources),供客户端调用。


import org.noear.snack4.Feature;
import org.noear.snack4.ONode;
import org.noear.solon.ai.annotation.ResourceMapping;
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.chat.prompt.Prompt;
import org.noear.solon.ai.chat.prompt.PromptImpl;
import org.noear.solon.ai.chat.skill.Skill;
import org.noear.solon.ai.chat.tool.FunctionTool;
import org.noear.solon.lang.Preview;

import java.util.Collection;
import java.util.List;

/**
 * MCP 服务端技能适配器
 * <p>
 * 职责:将本地定义的 {@link Skill} 逻辑通过 MCP 协议导出。
 * 机制:利用注解将技能的生命周期方法(isSupported, getInstruction)映射为 MCP 的 Tool 或 Resource,
 * 供远程 {@link org.noear.solon.ai.mcp.client.McpSkillClient} 发现并调用。
 *
 * @author noear
 * @since 3.9.0
 */
@Preview("3.9.0")
public abstract class McpSkillServer implements Skill {

    /**
     * 导出技能元数据作为 MCP 资源
     */
    @ResourceMapping(uri = "skill://metadataMcp", meta = "{hide:1}")
    public String metadataMcp() {
        return ONode.serialize(this.metadata());
    }

    /**
     * 导出准入检查逻辑为 MCP 工具
     * <p>
     * 注意:此工具标记为 hide,通常由客户端代理调用,不对最终 LLM 暴露
     */
    @ToolMapping(meta = "{hide:1}", description = "禁止 llm 使用")
    public boolean isSupportedMcp(String promptJson) {
        Prompt prompt = ONode.deserialize(promptJson, PromptImpl.class, Feature.Read_AutoType);
        return this.isSupported(prompt);
    }

    /**
     * 导出指令获取逻辑为 MCP 工具
     */
    @ToolMapping(meta = "{hide:1}", description = "禁止 llm 使用")
    public String getInstructionMcp(String promptJson) {
        Prompt prompt = ONode.deserialize(promptJson, PromptImpl.class, Feature.Read_AutoType);
        return this.getInstruction(prompt);
    }

    @ToolMapping(meta = "{hide:1}", description = "禁止 llm 使用")
    public List<String> getToolsMcp(String promptJson) {
        Prompt prompt = ONode.deserialize(promptJson, PromptImpl.class, Feature.Read_AutoType);
        return this.getToolsName(prompt);
    }

    @ToolMapping(meta = "{hide:1}", description = "禁止 llm 使用")
    public void onAttachMcp(String promptJson) {
        Prompt prompt = ONode.deserialize(promptJson, PromptImpl.class, Feature.Read_AutoType);
        this.onAttach(prompt);
    }

    @Override
    public final Collection<FunctionTool> getTools(Prompt prompt) {
        //不充许重载
        return null;
    }

    public List<String> getToolsName(Prompt prompt){
        // null 表示所有工具; 空表示无工具匹配;有表示要过滤名字
        return null;
    }
}