Spring AI

参考文档

模型上下文协议(MCP)

MCP 注解

MCP 注解示例

MCP 注解示例

本页提供了在 Spring AI 应用程序中使用 MCP 注解的全面示例。


完整应用示例

简单计算器服务器

一个提供计算器工具的 MCP 服务器完整示例:

@SpringBootApplication
public class CalculatorServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(CalculatorServerApplication.class, args);
    }
}

@Component
public class CalculatorTools {

    @McpTool(name = "add", description = "两个数相加")
    public double add(
            @McpToolParam(description = "第一个数", required = true) double a,
            @McpToolParam(description = "第二个数", required = true) double b) {
        return a + b;
    }

    @McpTool(name = "subtract", description = "两个数相减")
    public double subtract(
            @McpToolParam(description = "第一个数", required = true) double a,
            @McpToolParam(description = "第二个数", required = true) double b) {
        return a - b;
    }

    @McpTool(name = "multiply", description = "两个数相乘")
    public double multiply(
            @McpToolParam(description = "第一个数", required = true) double a,
            @McpToolParam(description = "第二个数", required = true) double b) {
        return a * b;
    }

    @McpTool(name = "divide", description = "两个数相除")
    public double divide(
            @McpToolParam(description = "被除数", required = true) double dividend,
            @McpToolParam(description = "除数", required = true) double divisor) {
        if (divisor == 0) {
            throw new IllegalArgumentException("除数不能为零");
        }
        return dividend / divisor;
    }

    @McpTool(name = "calculate-expression",
             description = "计算复杂的数学表达式")
    public CallToolResult calculateExpression(
            CallToolRequest request,
            McpSyncRequestContext context) {

        Map<String, Object> args = request.arguments();
        String expression = (String) args.get("expression");

        // 使用便捷的日志方法
        context.info("正在计算: " + expression);

        try {
            double result = evaluateExpression(expression);
            return CallToolResult.builder()
                .addTextContent("结果: " + result)
                .build();
        } catch (Exception e) {
            return CallToolResult.builder()
                .isError(true)
                .addTextContent("错误: " + e.getMessage())
                .build();
        }
    }
}

配置:

spring:
  ai:
    mcp:
      server:
        name: calculator-server
        version: 1.0.0
        type: SYNC
        protocol: SSE  # 或 STDIO、STREAMABLE
        capabilities:
          tool: true
          resource: true
          prompt: true
          completion: true

文档处理服务器

一个带有资源和提示词功能的文档处理服务器示例:

@Component
public class DocumentServer {

    private final Map<String, Document> documents = new ConcurrentHashMap<>();

    @McpResource(
        uri = "document://{id}",
        name = "文档",
        description = "访问存储的文档")
    public ReadResourceResult getDocument(String id, McpMeta meta) {
        Document doc = documents.get(id);

        if (doc == null) {
            return ReadResourceResult.builder(List.of(
                new TextResourceContents("document://" + id,
                    "text/plain", "未找到文档")
            )).build();
        }

        // 从元数据中检查访问权限
        String accessLevel = (String) meta.get("accessLevel");
        if ("restricted".equals(doc.getClassification()) &&
            !"admin".equals(accessLevel)) {
            return ReadResourceResult.builder(List.of(
                new TextResourceContents("document://" + id,
                    "text/plain", "访问被拒绝")
            )).build();
        }

        return ReadResourceResult.builder(List.of(
            new TextResourceContents("document://" + id,
                doc.getMimeType(), doc.getContent())
        )).build();
    }

    @McpTool(name = "analyze-document",
             description = "分析文档内容")
    public String analyzeDocument(
            McpSyncRequestContext context,
            @McpToolParam(description = "文档ID", required = true) String docId,
            @McpToolParam(description = "分析类型", required = false) String type) {

        Document doc = documents.get(docId);
        if (doc == null) {
            return "未找到文档";
        }

        // 从上下文获取进度令牌
        String progressToken = context.request().progressToken();

        if (progressToken != null) {
            context.progress(p -> p.progress(0.0).total(1.0).message("开始分析"));
        }

        // 执行分析
        String analysisType = type != null ? type : "summary";
        String result = performAnalysis(doc, analysisType);

        if (progressToken != null) {
            context.progress(p -> p.progress(1.0).total(1.0).message("分析完成"));
        }

        return result;
    }

    @McpPrompt(
        name = "document-summary",
        description = "生成文档摘要提示词")
    public GetPromptResult documentSummaryPrompt(
            @McpArg(name = "docId", required = true) String docId,
            @McpArg(name = "length", required = false) String length) {

        Document doc = documents.get(docId);
        if (doc == null) {
            return GetPromptResult.builder(List.of(new PromptMessage(Role.SYSTEM,
                    TextContent.builder("未找到文档").build())))
                .description("错误")
                .build();
        }

        String promptText = String.format(
            "请用%s总结以下文档:\n\n%s",
            length != null ? length : "几个段落",
            doc.getContent()
        );

        return GetPromptResult.builder(List.of(new PromptMessage(Role.USER, TextContent.builder(promptText).build())))
            .description("文档摘要")
            .build();
    }

    @McpComplete(prompt = "document-summary")
    public List<String> completeDocumentId(String prefix) {
        return documents.keySet().stream()
            .filter(id -> id.startsWith(prefix))
            .sorted()
            .limit(10)
            .toList();
    }
}

带处理器的 MCP 客户端

一个带有各种处理器的完整 MCP 客户端应用程序:

@SpringBootApplication
public class McpClientApplication {
    public static void main(String[] args) {
        SpringApplication.run(McpClientApplication.class, args);
    }
}

@Component
public class ClientHandlers {

    private final Logger logger = LoggerFactory.getLogger(ClientHandlers.class);
    private final ProgressTracker progressTracker = new ProgressTracker();
    private final ChatModel chatModel;

    public ClientHandlers(@Lazy ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    @McpLogging(clients = "server1")
    public void handleLogging(LoggingMessageNotification notification) {
        switch (notification.level()) {
            case ERROR:
                logger.error("[MCP] {} - {}", notification.logger(), notification.data());
                break;
            case WARNING:
                logger.warn("[MCP] {} - {}", notification.logger(), notification.data());
                break;
            case INFO:
                logger.info("[MCP] {} - {}", notification.logger(), notification.data());
                break;
            default:
                logger.debug("[MCP] {} - {}", notification.logger(), notification.data());
        }
    }

    @McpSampling(clients = "server1")
    public CreateMessageResult handleSampling(CreateMessageRequest request) {
        // 使用 Spring AI ChatModel 进行采样
        List<Message> messages = request.messages().stream()
            .map(msg -> {
                if (msg.role() == Role.USER) {
                    return new UserMessage(((TextContent) msg.content()).text());
                } else {
                    return AssistantMessage.builder()
                        .content(((TextContent) msg.content()).text())
                        .build();
                }
            })
            .toList();

        ChatResponse response = chatModel.call(new Prompt(messages));

        return CreateMessageResult.builder(Role.ASSISTANT,
                response.getResult().getOutput().getText(),
                request.modelPreferences().hints().get(0).name())
            .build();
    }

    @McpElicitation(clients = "server1")
    public ElicitResult handleElicitation(ElicitRequest request) {
        // 在实际应用中,这里会显示一个 UI 对话框
        Map<String, Object> userData = new HashMap<>();

        logger.info("请求交互: {}", request.message());

        // 根据模式模拟用户输入
        Map<String, Object> schema = request.requestedSchema();
        if (schema != null && schema.containsKey("properties")) {
            Map<String, Object> properties = (Map<String, Object>) schema.get("properties");

            properties.forEach((key, value) -> {
                // 在实际应用中,会提示用户输入每个字段
                userData.put(key, getDefaultValueForProperty(key, value));
            });
        }

        return new ElicitResult(ElicitResult.Action.ACCEPT, userData);
    }

    @McpProgress(clients = "server1")
    public void handleProgress(ProgressNotification notification) {
        progressTracker.update(
            notification.progressToken(),
            notification.progress(),
            notification.total(),
            notification.message()
        );

        // 更新 UI 或发送 WebSocket 通知
        broadcastProgress(notification);
    }

    @McpToolListChanged(clients = "server1")
    public void handleServer1ToolsChanged(List<McpSchema.Tool> tools) {
        logger.info("Server1 工具已更新:{} 个工具可用", tools.size());

        // 更新工具注册表
        toolRegistry.updateServerTools("server1", tools);

        // 通知 UI 刷新工具列表
        eventBus.publish(new ToolsUpdatedEvent("server1", tools));
    }

    @McpResourceListChanged(clients = "server1")
    public void handleServer1ResourcesChanged(List<McpSchema.Resource> resources) {
        logger.info("Server1 资源已更新:{} 个资源可用", resources.size());

        // 清除该服务器的资源缓存
        resourceCache.clearServer("server1");

        // 注册新资源
        resources.forEach(resource ->
            resourceCache.register("server1", resource));
    }
}

配置:

spring:
  ai:
    mcp:
      client:
        type: SYNC
        initialized: true
        request-timeout: 30s
        annotation-scanner:
          enabled: true
        sse:
          connections:
            server1:
              url: http://localhost:8080
        stdio:
          connections:
            local-tool:
              command: /usr/local/bin/mcp-tool
              args:
                - --mode=production

异步示例

异步工具服务器

@Component
public class AsyncDataProcessor {

    @McpTool(name = "fetch-data", description = "从外部源获取数据")
    public Mono<DataResult> fetchData(
            @McpToolParam(description = "数据源 URL", required = true) String url,
            @McpToolParam(description = "超时时间(秒)", required = false) Integer timeout) {

        Duration timeoutDuration = Duration.ofSeconds(timeout != null ? timeout : 30);

        return WebClient.create()
            .get()
            .uri(url)
            .retrieve()
            .bodyToMono(String.class)
            .map(data -> new DataResult(url, data, System.currentTimeMillis()))
            .timeout(timeoutDuration)
            .onErrorReturn(new DataResult(url, "获取数据出错", 0L));
    }

    @McpTool(name = "process-stream", description = "处理数据流")
    public Flux<String> processStream(
            McpAsyncRequestContext context,
            @McpToolParam(description = "项目数量", required = true) int count) {

        // 从上下文获取进度令牌
        String progressToken = context.request().progressToken();

        return Flux.range(1, count)
            .delayElements(Duration.ofMillis(100))
            .flatMap(i -> {
                if (progressToken != null) {
                    double progress = (double) i / count;
                    return context.progress(p -> p.progress(progress).total(1.0).message("正在处理项目 " + i))
                        .thenReturn("已处理项目 " + i);
                }
                return Mono.just("已处理项目 " + i);
            });
    }

    @McpResource(uri = "async-data://{id}", name = "异步数据")
    public Mono<ReadResourceResult> getAsyncData(String id) {
        return Mono.fromCallable(() -> loadDataAsync(id))
            .subscribeOn(Schedulers.boundedElastic())
            .map(data -> ReadResourceResult.builder(List.of(
                new TextResourceContents("async-data://" + id,
                    "application/json", data)
            )).build());
    }
}

异步客户端处理器

@Component
public class AsyncClientHandlers {

    @McpSampling(clients = "async-server")
    public Mono<CreateMessageResult> handleAsyncSampling(CreateMessageRequest request) {
        return Mono.fromCallable(() -> {
            // 为 LLM 准备请求
            String prompt = extractPrompt(request);
            return prompt;
        })
        .flatMap(prompt -> callLLMAsync(prompt))
        .map(response -> CreateMessageResult.builder(Role.ASSISTANT, response, "gpt-4")
            .build())
        .timeout(Duration.ofSeconds(30));
    }

    @McpProgress(clients = "async-server")
    public Mono<Void> handleAsyncProgress(ProgressNotification notification) {
        return Mono.fromRunnable(() -> {
            // 更新进度跟踪
            updateProgressAsync(notification);
        })
        .then(broadcastProgressAsync(notification))
        .subscribeOn(Schedulers.parallel());
    }

    @McpElicitation(clients = "async-server")
    public Mono<ElicitResult> handleAsyncElicitation(ElicitRequest request) {
        return showUserDialogAsync(request)
            .map(userData -> {
                if (userData != null && !userData.isEmpty()) {
                    return new ElicitResult(ElicitResult.Action.ACCEPT, userData);
                } else {
                    return new ElicitResult(ElicitResult.Action.DECLINE, null);
                }
            })
            .timeout(Duration.ofMinutes(5))
            .onErrorReturn(new ElicitResult(ElicitResult.Action.CANCEL, null));
    }
}

无状态服务器示例

@Component
public class StatelessTools {

    // 简单的无状态工具
    @McpTool(name = "format-text", description = "格式化文本")
    public String formatText(
            @McpToolParam(description = "要格式化的文本", required = true) String text,
            @McpToolParam(description = "格式类型", required = true) String format) {

        return switch (format.toLowerCase()) {
            case "uppercase" -> text.toUpperCase();
            case "lowercase" -> text.toLowerCase();
            case "title" -> toTitleCase(text);
            case "reverse" -> new StringBuilder(text).reverse().toString();
            default -> text;
        };
    }

    // 带传输上下文的无状态工具
    @McpTool(name = "validate-json", description = "验证 JSON")
    public CallToolResult validateJson(
            McpTransportContext context,
            @McpToolParam(description = "JSON 字符串", required = true) String json) {

        try {
            JsonMapper mapper = new JsonMapper();
            mapper.readTree(json);

            return CallToolResult.builder()
                .addTextContent("有效的 JSON")
                .structuredContent(Map.of("valid", true))
                .build();
        } catch (JacksonException e) {
            return CallToolResult.builder()
                .addTextContent("无效的 JSON: " + e.getMessage())
                .structuredContent(Map.of("valid", false, "error", e.getMessage()))
                .build();
        }
    }

    @McpResource(uri = "static://{path}", name = "静态资源")
    public String getStaticResource(String path) {
        // 简单的无状态资源
        return loadStaticContent(path);
    }

    @McpPrompt(name = "template", description = "模板提示词")
    public GetPromptResult templatePrompt(
            @McpArg(name = "template", required = true) String templateName,
            @McpArg(name = "variables", required = false) String variables) {

        String template = loadTemplate(templateName);
        if (variables != null) {
            template = substituteVariables(template, variables);
        }

        return GetPromptResult.builder(List.of(new PromptMessage(Role.USER, TextContent.builder(template).build())))
            .description("模板: " + templateName)
            .build();
    }
}

使用多个 LLM 提供商的 MCP 采样

此示例演示了如何使用 MCP 采样从多个 LLM 提供商生成创意内容,展示了基于注解的方法在服务器和客户端实现中的应用。

采样服务器实现

服务器提供了一个天气工具,使用 MCP 采样从不同的 LLM 提供商生成诗歌。

此示例直接使用 McpSyncServerExchange 以便对低级 MCP API 进行细粒度控制。对于更简单的情况,请使用 McpSyncRequestContext,它提供了更高级、更方便的接口(例如 context.sampleEnabled()context.sample(...)context.info(...))。

@Service
public class WeatherService {

    private final RestClient restClient = RestClient.create();

    public record WeatherResponse(Current current) {
        public record Current(LocalDateTime time, int interval, double temperature_2m) {
        }
    }

    @McpTool(description = "获取特定位置的温度(摄氏度)")
    public String getTemperature2(McpSyncServerExchange exchange,
            @McpToolParam(description = "位置纬度") double latitude,
            @McpToolParam(description = "位置经度") double longitude) {

        // 获取天气数据
        WeatherResponse weatherResponse = restClient
                .get()
                .uri("https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}&current=temperature_2m",
                        latitude, longitude)
                .retrieve()
                .body(WeatherResponse.class);

        StringBuilder openAiWeatherPoem = new StringBuilder();
        StringBuilder anthropicWeatherPoem = new StringBuilder();

        // 发送日志通知
        exchange.loggingNotification(LoggingMessageNotification.builder(LoggingLevel.INFO, "开始采样")
                .build());

        // 检查客户端是否支持采样
        if (exchange.getClientCapabilities().sampling() != null) {
            var samplingMessages = List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER,
                    McpSchema.TextContent.builder(
                            "请写一首关于这个天气预报的诗(温度单位为摄氏度)。使用 Markdown 格式:\n "
                                    + new JsonHelper().toJson(weatherResponse)).build()));
            var messageRequestBuilder = McpSchema.CreateMessageRequest.builder(samplingMessages, 500)
                    .systemPrompt("你是一位诗人!");

            // 向 OpenAI 请求诗歌
            var openAiLlmMessageRequest = messageRequestBuilder
                    .modelPreferences(ModelPreferences.builder().addHint("openai").build())
                    .build();
            CreateMessageResult openAiLlmResponse = exchange.createMessage(openAiLlmMessageRequest);
            openAiWeatherPoem.append(((McpSchema.TextContent) openAiLlmResponse.content()).text());

            // 向 Anthropic 请求诗歌
            var anthropicLlmMessageRequest = messageRequestBuilder
                    .modelPreferences(ModelPreferences.builder().addHint("anthropic").build())
                    .build();
            CreateMessageResult anthropicAiLlmResponse = exchange.createMessage(anthropicLlmMessageRequest);
            anthropicWeatherPoem.append(((McpSchema.TextContent) anthropicAiLlmResponse.content()).text());
        }

        exchange.loggingNotification(LoggingMessageNotification.builder(LoggingLevel.INFO, "完成采样")
                .build());

        // 合并结果
        String responseWithPoems = "OpenAI 关于天气的诗:" + openAiWeatherPoem.toString() + "\n\n" +
                "Anthropic 关于天气的诗:" + anthropicWeatherPoem.toString() + "\n"
                + new JsonHelper().toJson(weatherResponse);

        return responseWithPoems;
    }
}

采样客户端实现

客户端根据模型提示将采样请求路由到相应的 LLM 提供商:

@Service
public class McpClientHandlers {

    private static final Logger logger = LoggerFactory.getLogger(McpClientHandlers.class);

    @Autowired
    Map<String, ChatClient> chatClients;

    @McpProgress(clients = "server1")
    public void progressHandler(ProgressNotification progressNotification) {
        logger.info("MCP 进度: [{}] 进度: {} 总计: {} 消息: {}",
                progressNotification.progressToken(), progressNotification.progress(),
                progressNotification.total(), progressNotification.message());
    }

    @McpLogging(clients = "server1")
    public void loggingHandler(LoggingMessageNotification loggingMessage) {
        logger.info("MCP 日志: [{}] {}", loggingMessage.level(), loggingMessage.data());
    }

    @McpSampling(clients = "server1")
    public CreateMessageResult samplingHandler(CreateMessageRequest llmRequest) {
        logger.info("MCP 采样: {}", llmRequest);

        // 提取用户提示和模型提示
        var userPrompt = ((McpSchema.TextContent) llmRequest.messages().get(0).content()).text();
        String modelHint = llmRequest.modelPreferences().hints().get(0).name();

        // 根据模型提示查找合适的 ChatClient
        ChatClient hintedChatClient = chatClients.entrySet().stream()
                .filter(e -> e.getKey().contains(modelHint))
                .findFirst()
                .orElseThrow()
                .getValue();

        // 使用选中的模型生成响应
        String response = hintedChatClient.prompt()
                .system(llmRequest.systemPrompt())
                .user(userPrompt)
                .call()
                .content();

        return CreateMessageResult.builder(Role.ASSISTANT, response, modelHint)
                .build();
    }
}

客户端应用设置

在客户端应用程序中注册 MCP 工具和处理器:

@SpringBootApplication
public class McpClientApplication {

    public static void main(String[] args) {
        SpringApplication.run(McpClientApplication.class, args).close();
    }

    @Bean
    public CommandLineRunner predefinedQuestions(OpenAiChatModel openAiChatModel,
            ToolCallbackProvider mcpToolProvider) {

        return args -> {

            ChatClient chatClient = ChatClient.builder(openAiChatModel)
                    .defaultTools(mcpToolProvider)
                    .build();

            String userQuestion = """
                    阿姆斯特丹现在的天气如何?
                    请包含所有 LLM 提供商的创意响应。
                    在其他提供商的响应之后,添加一首综合所有其他提供商诗歌的诗。
                    """;

            System.out.println("> 用户: " + userQuestion);
            System.out.println("> 助手: " + chatClient.prompt(userQuestion).call().content());
        };
    }
}

配置

服务器配置
# 服务器 application.properties
spring.ai.mcp.server.name=mcp-sampling-server-annotations
spring.ai.mcp.server.version=0.0.1
spring.ai.mcp.server.protocol=STREAMABLE
spring.main.banner-mode=off
客户端配置
# 客户端 application.properties
spring.application.name=mcp
spring.main.web-application-type=none

# 为多个模型禁用默认的聊天客户端自动配置
spring.ai.chat.client.enabled=false

# API 密钥
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.anthropic.api-key=${ANTHROPIC_API_KEY}

# 使用 stateless-http 传输的 MCP 客户端连接
spring.ai.mcp.client.streamable-http.connections.server1.url=http://localhost:8080

# 禁用工具回调以防止循环依赖
spring.ai.mcp.client.toolcallback.enabled=false

演示的关键功能

  • 多模型采样:服务器使用模型提示从多个 LLM 提供商请求内容
  • 基于注解的处理器:客户端使用 @McpSampling@McpLogging@McpProgress 注解
  • 无状态 HTTP 传输:使用可流式传输协议进行通信
  • 创意内容生成:从不同模型生成关于天气数据的诗歌
  • 统一响应处理:将来自多个提供商的响应合并为单个结果

示例输出

运行客户端时,您将看到类似如下的输出:

> 用户: 阿姆斯特丹现在的天气如何?
请包含所有 LLM 提供商的创意响应。
在其他提供商的响应之后,添加一首综合所有其他提供商诗歌的诗。

> 助手:
OpenAI 关于天气的诗:
**阿姆斯特丹的冬日低语**
*温度:4.2°C*

在阿姆斯特丹的怀抱中,运河映照着天空,
4.2 度的轻柔寒意飘过...

Anthropic 关于天气的诗:
**运河边的沉思**
*当前条件:4.2°C*

在自行车休憩的水道旁,
冬日的空气考验着阿姆斯特丹...

天气数据:
{
  "current": {
    "time": "2025-01-23T11:00",
    "interval": 900,
    "temperature_2m": 4.2
  }
}

与 Spring AI 集成

展示 MCP 工具与 Spring AI 函数调用集成的示例:

@RestController
@RequestMapping("/chat")
public class ChatController {

    private final ChatModel chatModel;
    private final SyncMcpToolCallbackProvider toolCallbackProvider;

    public ChatController(ChatModel chatModel,
                          SyncMcpToolCallbackProvider toolCallbackProvider) {
        this.chatModel = chatModel;
        this.toolCallbackProvider = toolCallbackProvider;
    }

    @PostMapping
    public ChatResponse chat(@RequestBody ChatRequest request) {
        // 将 MCP 工具作为 Spring AI 函数回调获取
        ToolCallback[] mcpTools = toolCallbackProvider.getToolCallbacks();

        // 创建带有 MCP 工具的提示词
        Prompt prompt = new Prompt(
            request.getMessage(),
            ChatOptionsBuilder.builder()
                .withTools(mcpTools)
                .build()
        );

        // 调用带有 MCP 工具的聊天模型
        return chatModel.call(prompt);
    }
}

@Component
public class WeatherTools {

    @McpTool(name = "get-weather", description = "获取当前天气")
    public WeatherInfo getWeather(
            @McpToolParam(description = "城市名称", required = true) String city,
            @McpToolParam(description = "单位(metric/imperial)", required = false) String units) {

        String unit = units != null ? units : "metric";

        // 调用天气 API
        return weatherService.getCurrentWeather(city, unit);
    }

    @McpTool(name = "get-forecast", description = "获取天气预报")
    public ForecastInfo getForecast(
            @McpToolParam(description = "城市名称", required = true) String city,
            @McpToolParam(description = "天数(1-7)", required = false) Integer days) {

        int forecastDays = days != null ? days : 3;

        return weatherService.getForecast(city, forecastDays);
    }
}

其他资源

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐