主旨
这篇文章主旨就一点,罗列spring-ai对mcp集成导致出现的一系列问题
分析
由于mcp未问世之前,就早就已经有了工具调用,源码如下:
public interface ToolCallback {
/**
* Definition used by the AI model to determine when and how to call the tool.
*/
ToolDefinition getToolDefinition();
/**
* Metadata providing additional information on how to handle the tool.
*/
default ToolMetadata getToolMetadata() {
return ToolMetadata.builder().build();
}
/**
* Execute tool with the given input and return the result to send back to the AI
* model.
*/
String call(String toolInput);
/**
* Execute tool with the given input and context, and return the result to send back
* to the AI model.
*/
default String call(String toolInput, @Nullable ToolContext tooContext) {
if (tooContext != null && !tooContext.getContext().isEmpty()) {
throw new UnsupportedOperationException("Tool context is not supported!");
}
return call(toolInput);
}
}
该实体接口就是在spring-ai环境下,工具操作就是靠他实现,ToolDefinition是最主要的工具定义,ToolCallback进行操作封装等扩展,这显得很完美,但这只是存在于spring-ai环境下很完美。
随着MCP爆火,spring作为一个啥都要集成进去来巩固地位的集大成框架,MCP的集成也自然落到了spirng-ai头上,先不说问题,先讲一下它的实现思路吧,以及对mcp官网提供包的集成。
先讲一下mcp原生sdk,它提供了和spring-ai 相似的体系,也是有一个和ToolDefinition相似的类叫做McpSchema,这是一个规范是基于json schema的POJO化,也拥有除了tool规范定义的,其他东西的定义(相当于协议层)
mcp的sdk中也有一个McpServerFeatures类,提供了SyncToolSpecification(同步版),AsyncResourceSpecification(异步版)等等这种具体实现,类似于spring-ai下的ToolCallback。(相当于实现层)
列一个式子
ToolDefinition ≈ McpSchema.Tool
ToolCallback ≈ McpServerFeatures.SyncToolSpecification(这个有同步和异步两个版本)
public record SyncToolSpecification(McpSchema.Tool tool,
BiFunction<McpSyncServerExchange, Map<String, Object>, McpSchema.CallToolResult> call) {
}
上面就是SyncToolSpecification(下文都以同步版为例,不再提及,逻辑相同),由于mcp官方提供的SDK是raw版本,不具备任何开箱即用功能,基本就是仅仅规范了协议。所以这个call函数(工具调用函数)都需要应用框架来自行实现。下面来看看spring-ai是如何实现的:
public McpSyncServer mcpSyncServer(McpServerTransportProvider transportProvider,
McpSchema.ServerCapabilities.Builder capabilitiesBuilder, McpServerProperties serverProperties,
ObjectProvider<List<SyncToolSpecification>> tools,
ObjectProvider<List<SyncResourceSpecification>> resources,
ObjectProvider<List<SyncPromptSpecification>> prompts,
ObjectProvider<List<SyncCompletionSpecification>> completions,
ObjectProvider<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers,
List<ToolCallbackProvider> toolCallbackProvider) {
McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(),
serverProperties.getVersion());
// Create the server with both tool and resource capabilities
SyncSpecification serverBuilder = McpServer.sync(transportProvider).serverInfo(serverInfo);
List<SyncToolSpecification> toolSpecifications = new ArrayList<>(tools.stream().flatMap(List::stream).toList());
List<ToolCallback> providerToolCallbacks = toolCallbackProvider.stream()
.map(pr -> List.of(pr.getToolCallbacks()))
.flatMap(List::stream)
.filter(fc -> fc instanceof ToolCallback)
.map(fc -> (ToolCallback) fc)
.toList();
toolSpecifications.addAll(this.toSyncToolSpecifications(providerToolCallbacks, serverProperties));
if (!CollectionUtils.isEmpty(toolSpecifications)) {
serverBuilder.tools(toolSpecifications);
capabilitiesBuilder.tools(serverProperties.isToolChangeNotification());
logger.info("Registered tools: " + toolSpecifications.size() + ", notification: "
+ serverProperties.isToolChangeNotification());
}
List<SyncResourceSpecification> resourceSpecifications = resources.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(resourceSpecifications)) {
serverBuilder.resources(resourceSpecifications);
capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification());
logger.info("Registered resources: " + resourceSpecifications.size() + ", notification: "
+ serverProperties.isResourceChangeNotification());
}
List<SyncPromptSpecification> promptSpecifications = prompts.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(promptSpecifications)) {
serverBuilder.prompts(promptSpecifications);
capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification());
logger.info("Registered prompts: " + promptSpecifications.size() + ", notification: "
+ serverProperties.isPromptChangeNotification());
}
List<SyncCompletionSpecification> completionSpecifications = completions.stream()
.flatMap(List::stream)
.toList();
if (!CollectionUtils.isEmpty(completionSpecifications)) {
serverBuilder.completions(completionSpecifications);
capabilitiesBuilder.completions();
logger.info("Registered completions: " + completionSpecifications.size());
}
rootsChangeConsumers.ifAvailable(consumer -> {
serverBuilder.rootsChangeHandler((exchange, roots) -> consumer.accept(exchange, roots));
logger.info("Registered roots change consumer");
});
serverBuilder.capabilities(capabilitiesBuilder.build());
serverBuilder.instructions(serverProperties.getInstructions());
return serverBuilder.build();
}
简述一下把,只考虑Tool这一最核心部分,SyncToolSpecification是MCP的SDK里定义的,这一API过于低级,因为不可能让Call函数都让用户去写,所以ToolCallbackProvider这一在spring-ai原始环境就存在的定义 便可以很轻易地完成转换(convert),ToolCallbackProvider作为一个接口(作用是提供ToolCallback,举个具体的例子),比如其实现类 MethodToolCallbackProvider 可以根据传入的 类 来解析其方法 加上一些条件(是否有@Tool注解,有的话就根据注解里的name,description注解来构造ToolDefinition,从而构造ToolCallback)。
这里看起来好像就只需要在ToolCallback 和 SyncToolSpecification 之间 做一个转换函数,好像就可以了,你我这样想,spring-ai也这样想,于是有了下面函数(核心实现):
public static McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCallback toolCallback,
MimeType mimeType) {
var tool = new McpSchema.Tool(toolCallback.getToolDefinition().name(),
toolCallback.getToolDefinition().description(), toolCallback.getToolDefinition().inputSchema());
return new McpServerFeatures.SyncToolSpecification(tool, (exchange, request) -> {
try {
String callResult = toolCallback.call(ModelOptionsUtils.toJsonString(request),
new ToolContext(Map.of(TOOL_CONTEXT_MCP_EXCHANGE_KEY, exchange)));
if (mimeType != null && mimeType.toString().startsWith("image")) {
return new McpSchema.CallToolResult(List
.of(new McpSchema.ImageContent(List.of(Role.ASSISTANT), null, callResult, mimeType.toString())),
false);
}
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(callResult)), false);
}
catch (Exception e) {
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(e.getMessage())), true);
}
});
}
这个代码先不去管,到现在万事大吉,咋们来走一下MCP的tool/call流程,如下图所示:
如果只看这张图好像还算清晰明了,好像也能顺利执行,但spring-ai对MCP的支持也就仅限于我讲的这些了,下面我会标出他的问题。
问题
兼容性
这是一个非常非常严重的问题,在ToolCallback进行Call(工具调用)的时候,会将方法的返回值经过一个 默认转化器(可以在@Tool注解里指定)进行格式转换,代码如下:
@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Tool {
/**
* The name of the tool. If not provided, the method name will be used.
*/
String name() default "";
/**
* The description of the tool. If not provided, the method name will be used.
*/
String description() default "";
/**
* Whether the tool result should be returned directly or passed back to the model.
*/
boolean returnDirect() default false;
/**
* The class to use to convert the tool call result to a String.
*/
Class<? extends ToolCallResultConverter> resultConverter() default DefaultToolCallResultConverter.class;
}
public final class DefaultToolCallResultConverter implements ToolCallResultConverter {
private static final Logger logger = LoggerFactory.getLogger(DefaultToolCallResultConverter.class);
@Override
public String convert(@Nullable Object result, @Nullable Type returnType) {
if (returnType == Void.TYPE) {
logger.debug("The tool has no return type. Converting to conventional response.");
return JsonParser.toJson("Done");
}
if (result instanceof RenderedImage) {
final var buf = new ByteArrayOutputStream(1024 * 4);
try {
ImageIO.write((RenderedImage) result, "PNG", buf);
}
catch (IOException e) {
return "Failed to convert tool result to a base64 image: " + e.getMessage();
}
final var imgB64 = Base64.getEncoder().encodeToString(buf.toByteArray());
return JsonParser.toJson(Map.of("mimeType", "image/png", "data", imgB64));
}
else {
logger.debug("Converting tool result to JSON.");
return JsonParser.toJson(result);
}
}
}
明显,toSyncToolSpecification 函数 和 covert函数起了冲突,进行了一个重复结果封装效果,逻辑冲突,mcp的返回协议规范和spring-ai返回应用规范嵌套冲突,从mcp inspector 工具里可以查看到,如下图所示:
协议规范重复封装,导致可读性极差,极低扩展性,还不如设一个接口,可以共用户自行选择实现协议。
实际上导致这一切根本上讲 就是spring-ai的投机取巧,把以前Tool的所有东西都直接转到Mcp的Tool里,甚至连一个MCPTool注解都没写, 只有一个转换函数,连适配器都没有,这相当大弊端了,代码生硬不优雅。
扩展性
相比各位读者,如果仔细读了上述代码,也就能看出来上述代码其实相当普通,扩展性极为有限,这一切根源肯定是因为MCP极为宽泛,和HTTP这种极度落地的协议还是有着相当差距的,但MCP官网又推出了对应SDK,由官方推出的SDK自然而然就成为了钦点唯一 MCP SDK,但遇到更加具体,协议中未曾定义的问题就显得非常麻烦,就比如多模态数据返回。MCP SDK,定义了三种返回类型,text,image,以及一个resource(Mcp里的资源模块)
最佳实践,重写spiring-ai的 工具结果转化器,设立规范,按照mcpSdk定义的规范,text,Image和Resource来进行结果转化。
其实本可以很便捷,MCP工具可以很容易的实现新版本协议的annotation扩展以及返回mineType的扩展,只是因为spring-ai固执的使用原有Tool注解,使得功能难以更新,架构烂尾。
总结
尽管如此spring-ai框架是一款出色的应用性框架,其架构理念颠覆以往所有类型框架,ai技术日新月异,扩展性基于前瞻性,实际上我理解扩展性极为把 Dog,抽取出一个接口Animals,然后提取公共属性,好的扩展性,需要架构者,能够在有功能新加进来时,仍不改变原始结构。
关于mcp扩展点实在蛮多,我会继续基于我现在的项目进行功能升级,后面也要进行架构重组
项目地址:
gitee: https://gitee.com/facedog/server2mcp-spring-boot-starter.git
github:https://github.com/TheEterna/server2mcp-spring-boot-starter.git
希望大家可以多多star,感谢