从零构建一个 MCP Agent 运行时(二):Tool 注册与发现,干净一点能死吗

Tool Registry 是整个 Runtime 的入口。这篇从 ToolDefinition 的数据模型开始,讲到动态发现、MCP Schema 转化、Interceptor 链,最后落地成一个可以跑起来的注册中心。

Part-01 最后留了个 ToolDefinition.builder() 的预告,我说这套 Runtime 的 tool 注册要比 Spring AI 干净。今天兑现这个承诺,把 Tool Registry 从头到尾拆开。

起点:一个 tool 描述需要承载什么

在 MCP 协议体系里,一个 tool 的描述包含三个层次。

第一层,协议层。MCP4J 的 Tool 对象需要 name、description、以及一个 JsonSchema 格式的 inputSchema。这是 MCP 客户端与服务端之间的通信契约。

第二层,运行时层。除了协议信息,Runtime 还需要知道这个 tool 怎么执行——就是那个 input → output 的函数。以及它在执行前后要经过哪些拦截器。

第三层,可观测层。每个 tool 应该有元信息——它的 owner、版本、超时默认值、限流配额。这些在协议层不需要,但在生产环境里是刚需。

多数框架把这三层揉在一起。Spring AI 的 @Tool 注解要同时承担协议声明 + 方法绑定 + AOP 拦截。当我只想加一个 timeout=5000 的参数时,发现得去改框架源码。

三层分开,每层独立演进,这才是 ToolDefinition 的设计原则。

ToolDefinition:不止是一个 POJO

先看最核心的数据模型:

public class ToolDefinition {

    private final String name;
    private final String description;
    private final JsonSchema inputSchema;
    private final ToolExecutor executor;
    private final List<ToolInterceptor> interceptors;
    private final ToolMetadata metadata;

    private ToolDefinition(Builder builder) {
        this.name = builder.name;
        this.description = builder.description;
        this.inputSchema = builder.inputSchema;
        this.executor = builder.executor;
        this.interceptors = List.copyOf(builder.interceptors);
        this.metadata = builder.metadata;
    }

    public static Builder builder() {
        return new Builder();
    }

    // getters omitted for brevity

    public static class Builder {
        private String name;
        private String description;
        private JsonSchema inputSchema;
        private ToolExecutor executor;
        private final List<ToolInterceptor> interceptors = new ArrayList<>();
        private ToolMetadata metadata = ToolMetadata.DEFAULT;

        public Builder name(String name) {
            this.name = name;
            return this;
        }

        public Builder description(String description) {
            this.description = description;
            return this;
        }

        public Builder parameterSchema(JsonSchema schema) {
            this.inputSchema = schema;
            return this;
        }

        public Builder executor(ToolExecutor executor) {
            this.executor = executor;
            return this;
        }

        public Builder withInterceptor(ToolInterceptor interceptor) {
            this.interceptors.add(interceptor);
            return this;
        }

        public Builder metadata(ToolMetadata metadata) {
            this.metadata = metadata;
            return this;
        }

        public ToolDefinition build() {
            Objects.requireNonNull(name, "tool name must not be null");
            Objects.requireNonNull(executor, "tool executor must not be null");
            if (inputSchema == null) {
                this.inputSchema = JsonSchema.empty();
            }
            return new ToolDefinition(this);
        }
    }
}

你可能会问,为什么 executor 是接口而不是 Function<Map<String,Object>, Object>

因为 tool 的执行上下文远比一个参数 Map 复杂。执行器需要知道当前会话 ID、Trace ID、调用来源的 Agent 标识。把这些信息硬塞进一个 Map<String,Object> 参数里,就是把自己的架构僵化在一个窄接口后面。

@FunctionalInterface
public interface ToolExecutor {
    Object execute(ToolExecutionContext ctx) throws Exception;
}

public class ToolExecutionContext {
    private final String sessionId;
    private final String traceId;
    private final String callId;           // MCP 的 JSON-RPC request ID
    private final Map<String, Object> args;
    private final Map<String, Object> context; // 扩展属性

    @SuppressWarnings("unchecked")
    public <T> T getArg(String name) {
        return (T) args.get(name);
    }

    @SuppressWarnings("unchecked")
    public <T> T getArgOrDefault(String name, T defaultValue) {
        return (T) args.getOrDefault(name, defaultValue);
    }
    // ...
}

这样设计的好处是:执行器只关心 ToolExecutionContext,不关心它从哪来、执行完后结果去哪。职责边界清晰。

JsonSchema:类型安全的关键

Tool 参数校验的起点是 JSON Schema。MCP4J 内部也用它。但 MCP4J 的 schema 构建方式偏底层——直接操作 Map<String,Object>,没有任何类型约束。

我要的是一个 builder,让参数 schema 的声明过程本身就是类型安全的:

public class JsonSchema {

    private final String type;          // 永远是 "object"
    private final Map<String, SchemaProperty> properties;
    private final List<String> required;

    private JsonSchema(Builder builder) {
        this.type = "object";
        this.properties = Map.copyOf(builder.properties);
        this.required = List.copyOf(builder.required);
    }

    public static Builder builder() {
        return new Builder();
    }

    public static JsonSchema empty() {
        return new Builder().build();
    }

    public Map<String, Object> toMap() {
        Map<String, Object> schema = new LinkedHashMap<>();
        schema.put("type", type);
        Map<String, Object> props = new LinkedHashMap<>();
        properties.forEach((name, prop) -> props.put(name, prop.toMap()));
        schema.put("properties", props);
        if (!required.isEmpty()) {
            schema.put("required", required);
        }
        return schema;
    }

    public static class Builder {
        private final Map<String, SchemaProperty> properties = new LinkedHashMap<>();
        private final List<String> required = new ArrayList<>();

        public Builder addProperty(String name, SchemaProperty property) {
            this.properties.put(name, property);
            return this;
        }

        public Builder addRequired(String name) {
            if (!this.required.contains(name)) {
                this.required.add(name);
            }
            return this;
        }

        public JsonSchema build() {
            return new JsonSchema(this);
        }
    }
}

public class SchemaProperty {

    private final String type;
    private final String description;
    private final List<Object> enumValues;
    private final Object defaultValue;

    private SchemaProperty(String type, String description,
                           List<Object> enumValues, Object defaultValue) {
        this.type = type;
        this.description = description;
        this.enumValues = enumValues;
        this.defaultValue = defaultValue;
    }

    public static SchemaProperty ofType(String type) {
        return new SchemaProperty(type, null, null, null);
    }

    public static final SchemaProperty STRING = ofType("string");
    public static final SchemaProperty INTEGER = ofType("integer");
    public static final SchemaProperty NUMBER = ofType("number");
    public static final SchemaProperty BOOLEAN = ofType("boolean");
    public static final SchemaProperty ARRAY = ofType("array");
    public static final SchemaProperty OBJECT = ofType("object");

    public SchemaProperty withDescription(String description) {
        return new SchemaProperty(this.type, description, this.enumValues, this.defaultValue);
    }

    public SchemaProperty withEnum(Object... values) {
        return new SchemaProperty(this.type, this.description,
                List.of(values), this.defaultValue);
    }

    public SchemaProperty withDefault(Object defaultValue) {
        return new SchemaProperty(this.type, this.description,
                this.enumValues, defaultValue);
    }

    public Map<String, Object> toMap() {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("type", type);
        if (description != null) map.put("description", description);
        if (enumValues != null && !enumValues.isEmpty()) map.put("enum", enumValues);
        if (defaultValue != null) map.put("default", defaultValue);
        return map;
    }
}

用法长这样:

JsonSchema schema = JsonSchema.builder()
    .addProperty("city", SchemaProperty.STRING
        .withDescription("城市名")
        .withEnum("北京", "上海", "深圳"))
    .addProperty("unit", SchemaProperty.STRING
        .withDefault("celsius")
        .withEnum("celsius", "fahrenheit"))
    .addRequired("city")
    .build();

toMap() 的输出就是一个标准的 JSON Schema,可以直接喂给 MCP4J 的 Tool.Builder.inputSchema()

ToolRegistry:不只是一个 Map

注册中心最容易写成 ConcurrentHashMap<String, ToolDefinition> 然后加几个方法就完事。但如果只做到这个程度,它就不值一个单独的模块。

Registry 需要做的三件事:

@Component
public class ToolRegistry {

    private final ConcurrentHashMap<String, ToolDefinition> tools = new ConcurrentHashMap<>();
    private final List<ToolRegistryListener> listeners = new CopyOnWriteArrayList<>();

    public void register(ToolDefinition tool) {
        ToolDefinition old = tools.putIfAbsent(tool.getName(), tool);
        if (old != null) {
            throw new DuplicateToolException(
                "Tool '" + tool.getName() + "' already registered");
        }
        listeners.forEach(l -> l.onToolRegistered(tool));
    }

    public void registerAll(Collection<ToolDefinition> tools) {
        tools.forEach(this::register);
    }

    public Optional<ToolDefinition> lookup(String name) {
        return Optional.ofNullable(tools.get(name));
    }

    public ToolDefinition getRequired(String name) {
        return lookup(name).orElseThrow(
            () -> new ToolNotFoundException("Tool not found: " + name));
    }

    public List<ToolDefinition> list() {
        return List.copyOf(tools.values());
    }

    public boolean contains(String name) {
        return tools.containsKey(name);
    }

    public void addListener(ToolRegistryListener listener) {
        this.listeners.add(listener);
    }

    public interface ToolRegistryListener {
        default void onToolRegistered(ToolDefinition tool) {}
        default void onToolRemoved(String toolName) {}
    }
}

几个设计决策值得说:

putIfAbsent 而非 put。同名 tool 覆盖会让调用方在毫不知情的情况下换掉了某个 tool 的实现。抛异常虽然暴力,但至少暴露了问题。

CopyOnWriteArrayList 作为监听器容器ToolRegistryListener 是给 MCP4J 集成、可观测模块、甚至动态热更新用的。注册时遍历通知,写操作少、读操作多,CopyOnWriteArrayList 刚好。

getRequiredlookup 的区分lookup 返回 Optional,调用方自己决定要不要兜底。getRequired 直接抛异常——当 Runtime 在调度时发现 tool 找不到,这就是一个不可恢复的错误,早点炸比晚点好。

动态发现:别用反射扫描

很多框架的动态发现 = 反射扫描 @Tool 注解。Spring AI 就是这么干的。然后你发现一个问题:你没法控制哪些类被扫描、哪些方法被注册。加一个 @ComponentScan 的 exclude filter 吧,Spring Boot 的自动配置又在背后帮你扫了一遍。

我的做法更直接。既然 tool 本身就是 Spring Bean,那直接依赖 Spring 的 ApplicationContext 做按需发现:

@Component
public class SpringToolDiscoverer {

    private final ApplicationContext applicationContext;

    public SpringToolDiscoverer(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    public List<ToolDefinition> discover() {
        return applicationContext.getBeansOfType(ToolDefinition.class)
            .values()
            .stream()
            .toList();
    }

    public void autoRegister(ToolRegistry registry) {
        discover().forEach(registry::register);
    }
}

ToolDefinition 本身是 Spring Bean。@Configuration 类里声明,@Bean 方法返回。没有注解扫描,没有反射遍历。Spring 容器本身就是你的"注册表"。

如果你有非 Spring 的环境,也可以实现一个 ToolDefinitionProvider 接口:

@FunctionalInterface
public interface ToolDefinitionProvider {
    List<ToolDefinition> provide();
}

// 用法:
@Component
public class WeatherToolProvider implements ToolDefinitionProvider {
    @Override
    public List<ToolDefinition> provide() {
        return List.of(weatherTool());
    }

    private ToolDefinition weatherTool() {
        return ToolDefinition.builder()
            .name("get_weather")
            .description("获取天气")
            .parameterSchema(/* ... */)
            .executor(ctx -> {
                String city = ctx.getArg("city");
                return "{\"city\":\"" + city + "\",\"temp\":22}";
            })
            .build();
    }
}

然后在初始化阶段调用:

@Component
public class ToolRegistryInitializer implements ApplicationRunner {

    private final ToolRegistry registry;
    private final List<ToolDefinitionProvider> providers;

    public ToolRegistryInitializer(ToolRegistry registry,
                                   List<ToolDefinitionProvider> providers) {
        this.registry = registry;
        this.providers = providers;
    }

    @Override
    public void run(ApplicationArguments args) {
        providers.stream()
            .flatMap(p -> p.provide().stream())
            .forEach(registry::register);
        log.info("Registered {} tools from {} providers",
            registry.list().size(), providers.size());
    }
}

MCP4J 集成:桥接 Runtime 世界和协议世界

Tool 注册好了,怎么把它暴露给 MCP 客户端?

MCP4J 提供了 McpServer.Builder.tool(Tool) 来注册 tool。Tool 是 MCP4J 协议层的 schema 对象,和我们的 ToolDefinition 是两个世界的概念。需要一个桥接转换:

@Component
public class McpToolBridge {

    private final ToolRegistry toolRegistry;
    private final ToolExecutorRouter executorRouter;

    public McpToolBridge(ToolRegistry toolRegistry,
                         ToolExecutorRouter executorRouter) {
        this.toolRegistry = toolRegistry;
        this.executorRouter = executorRouter;
    }

    /**
     * 把 Registry 里的所有 ToolDefinition 转换为 MCP4J 的 Tool 对象,
     * 然后注册到 McpServer 上。
     */
    public void registerToServer(McpServer.Builder serverBuilder) {
        toolRegistry.list().forEach(def -> {
            McpServer.Tool mcpTool = McpServer.Tool.builder()
                .name(def.getName())
                .description(def.getDescription())
                .inputSchema(def.getInputSchema().toMap())
                .withHandler(toolCall -> {
                    // toolCall 是 MCP4J 的 CallToolRequest,
                    // 需要转成我们的 Runtime 上下文
                    ToolExecutionContext ctx = ToolExecutionContext.builder()
                        .sessionId(toolCall.getSessionId())
                        .traceId(TraceContext.current().traceId())
                        .callId(toolCall.getId())
                        .args(toolCall.getArguments())
                        .build();

                    // 通过拦截器链执行
                    return executorRouter.execute(def, ctx);
                })
                .build();

            serverBuilder.tool(mcpTool);
        });
    }
}

ToolExecutorRouter 负责拦截器链的编排:

@Component
public class ToolExecutorRouter {

    public ToolExecutionResult execute(ToolDefinition def,
                                       ToolExecutionContext ctx) {
        // 构建拦截器链
        InterceptorChain chain = new InterceptorChain(def.getInterceptors());

        try {
            // 前置拦截
            chain.beforeExecute(ctx);

            // 实际执行
            long start = System.nanoTime();
            Object result = def.getExecutor().execute(ctx);
            long elapsed = System.nanoTime() - start;

            ToolExecutionResult executionResult = ToolExecutionResult.success(
                def.getName(), result, elapsed, ctx);

            // 后置拦截
            chain.afterExecute(ctx, executionResult);

            return executionResult;

        } catch (Exception e) {
            chain.onError(ctx, e);
            return ToolExecutionResult.failure(def.getName(), e, ctx);
        }
    }
}

拦截器接口很简单:

public interface ToolInterceptor {
    default void beforeExecute(ToolExecutionContext ctx) {}
    default void afterExecute(ToolExecutionContext ctx, ToolExecutionResult result) {}
    default void onError(ToolExecutionContext ctx, Exception e) {}
}

内置拦截器直接用 Spring 风格实现——RateLimitInterceptor 就是一个 @Component 注入到 ToolDefinition builder 里:

@Component
public class RateLimitInterceptor implements ToolInterceptor {

    private final Cache<String, AtomicInteger> counters = Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .build();

    private final int maxCalls;

    public RateLimitInterceptor(@Value("${tool.ratelimit.default:60}") int maxCalls) {
        this.maxCalls = maxCalls;
    }

    @Override
    public void beforeExecute(ToolExecutionContext ctx) {
        String key = ctx.getSessionId() + ":" + ctx.getArg("toolName");
        AtomicInteger counter = counters.get(key, k -> new AtomicInteger(0));
        if (counter.incrementAndGet() > maxCalls) {
            throw new ToolExecutionException("Rate limit exceeded for " + key);
        }
    }
}

跑起来的完整注册流程

把上面所有部件组装起来,在 Spring Boot 的配置类里走一遍完整的注册流程:

@Configuration
public class ToolRegistryConfig {

    @Bean
    public ToolDefinitionProvider weatherToolProvider(WeatherService weatherService) {
        return () -> List.of(
            ToolDefinition.builder()
                .name("get_weather")
                .description("获取指定城市的实时天气信息")
                .parameterSchema(JsonSchema.builder()
                    .addProperty("city", SchemaProperty.STRING
                        .withDescription("城市名,中文,如 北京、上海、东京")
                        .withEnum("北京", "上海", "广州", "深圳", "东京"))
                    .addProperty("unit", SchemaProperty.STRING
                        .withDefault("celsius")
                        .withEnum("celsius", "fahrenheit"))
                    .addRequired("city")
                    .build())
                .executor(ctx -> {
                    String city = ctx.getArg("city");
                    String unit = ctx.getArgOrDefault("unit", "celsius");
                    return weatherService.fetch(city, unit);
                })
                .withInterceptor(new LoggingInterceptor())
                .metadata(ToolMetadata.builder()
                    .timeout(Duration.ofSeconds(10))
                    .owner("weather-team")
                    .version("1.0.0")
                    .build())
                .build(),

            ToolDefinition.builder()
                .name("search_hotels")
                .description("搜索指定城市的酒店列表")
                .parameterSchema(JsonSchema.builder()
                    .addProperty("city", SchemaProperty.STRING
                        .withDescription("城市名"))
                    .addProperty("checkIn", SchemaProperty.STRING
                        .withDescription("入住日期,YYYY-MM-DD"))
                    .addProperty("checkOut", SchemaProperty.STRING
                        .withDescription("离店日期,YYYY-MM-DD"))
                    .addRequired("city")
                    .addRequired("checkIn")
                    .addRequired("checkOut")
                    .build())
                .executor(ctx -> hotelService.search(ctx.getArg("city"),
                    ctx.getArg("checkIn"), ctx.getArg("checkOut")))
                .build()
        );
    }

    @Bean
    public ApplicationRunner toolInitRunner(ToolRegistry registry,
                                            List<ToolDefinitionProvider> providers) {
        return args -> {
            providers.forEach(p -> p.provide().forEach(registry::register));
            log.info("Tool Registry initialized with {} tools", registry.list().size());

            // 注册到 MCP Server
            registry.addListener(new McpSyncListener(mcpServer));
        };
    }
}

McpSyncListener 保证 MCP Server 侧的 tool 列表与 Registry 保持同步:

public class McpSyncListener implements ToolRegistry.ToolRegistryListener {

    private final McpServer mcpServer;

    public McpSyncListener(McpServer mcpServer) {
        this.mcpServer = mcpServer;
    }

    @Override
    public void onToolRegistered(ToolDefinition tool) {
        mcpServer.addTool(convertToMcpTool(tool));
    }
}

算算账

对比 Spring AI 的方案和这套 Runtime 的方案,差异在三个维度:

第一,注册成本。 Spring AI 靠反射扫描 @Tool,写代码时看起来很爽——一行注解搞定。但需要加配置的每一处都在提示你:反射不是免费的。@Tool(description = "...") 里写长字符串时,你没有任何编译期校验。这套 Runtime 的 builder 虽然代码量多一点,但每个字段都是显式的、类型安全的。

第二,动态性。 Spring AI 的 tool 注册基本上发生在启动阶段,通过 @PostConstruct 或者 @Bean 自动注入。如果你想在运行时热注册或注销一个 tool,会发现 ToolCallbackProvider 接口根本就没考虑这个场景。我们的 ToolRegistry 在设计之初就预留了 ToolRegistryListener——热更新、灰度发布、A/B 测试都可以通过监听器机制接入。

第三,协议关注点分离。 Spring AI 把 @Tool 注解既当协议声明又当执行绑定,导致 tool 的定义和执行逻辑耦合在同一个类里。我们这三层(数据模型 → 注册中心 → MCP 桥接)各自独立,任何一层更换实现都不影响其他层。比如你想把 JSON Schema 换成 Protobuf,只需要改 JsonSchema.toMap() 那一个方法。

这一篇写了什么

ToolDefinition 的数据模型,三层分离的设计。JsonSchemaSchemaProperty 的类型安全 builder。ToolRegistry 不只是 Map,有监听器机制。动态发现不走反射,依赖 Spring 容器的原生能力。拦截器链做了简单的 before/after/error 编排。MCP4J 桥接把 Runtime 世界的 tool 注册到协议世界。

下一篇讲 Tool Execution Engine——当 LLM 说"调用 get_weather",从参数解析到结果返回的完整链路。包括参数类型校验、隐式类型转换、超时控制、以及我最头疼的一套兜底策略。

好,先写到这。

评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个