从零构建一个 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 刚好。
getRequired 和 lookup 的区分。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 的数据模型,三层分离的设计。JsonSchema 和 SchemaProperty 的类型安全 builder。ToolRegistry 不只是 Map,有监听器机制。动态发现不走反射,依赖 Spring 容器的原生能力。拦截器链做了简单的 before/after/error 编排。MCP4J 桥接把 Runtime 世界的 tool 注册到协议世界。
下一篇讲 Tool Execution Engine——当 LLM 说"调用 get_weather",从参数解析到结果返回的完整链路。包括参数类型校验、隐式类型转换、超时控制、以及我最头疼的一套兜底策略。
好,先写到这。
评论
发表评论