spring-ai mcp-server入门指南

0 阅读5分钟

摘要:本文主要介绍springboot3+ spring ai实现mcp-server来增加我们的生产力;下文用一个温度的查询和数据库查询的案例来实现开发步骤。

核心概念

Spring AI 提供了与 Model Context Protocol (MCP) ​ 的集成,这是一个新兴的协议,旨在为 AI 应用提供标准化工具和上下文管理方式。

MCP​ 是一个开放协议,让 AI 模型能够:

  • 发现和使用工具
  • 访问各种数据源
  • 管理上下文信息
  • 以标准化方式与外部系统交互

实战案例

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.9</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>sp3-mcp</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>sp3-mcp</name>
    <description>sp3-mcp</description>
    <url/>
    <licenses>
        <license/>
    </licenses>
    <developers>
        <developer/>
    </developers>
    <scm>
        <connection/>
        <developerConnection/>
        <tag/>
        <url/>
    </scm>
    <properties>
        <java.version>21</java.version>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
        </dependency>
        
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>1.1.2</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

</project>

application.properties

# spring.main.web-application-type=none

# NOTE: You must disable the banner and the console logging 
# to allow the STDIO transport to work !!!
spring.main.banner-mode=off
# logging.pattern.console=

# spring.ai.mcp.server.stdio=false

spring.ai.mcp.server.name=mcp-weather-server
spring.ai.mcp.server.request-timeout=1h

spring.ai.mcp.server.protocol=sse

logging.file.name=./mcp-weather-server/target/server.log

Application.java

@SpringBootApplication
public class Sp3McpApplication {

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

}

WeatherService.java

提供的温度查询的工具

@Service
public class WeatherService {

    private final RestClient restClient = RestClient.create();

    /**
     * The response format from the Open-Meteo API
     */
    public record WeatherResponse(Current current) {
        public record Current(LocalDateTime time, int interval, double temperature_2m) {
        }
    }

    @McpTool(description = "Get the temperature (in celsius) for a specific location")
    public String getTemperature(McpSyncServerExchange exchange,
                                 @McpToolParam(description = "The location latitude") double latitude,
                                 @McpToolParam(description = "The location longitude") double longitude,
                                 @McpProgressToken String progressToken) {

        exchange.loggingNotification(LoggingMessageNotification.builder()
                .level(LoggingLevel.DEBUG)
                .data("Call getTemperature Tool with latitude: " + latitude + " and longitude: " + longitude)
                .meta(Map.of()) // non null meata as a workaround for bug: ...
                .build());

        // 0% progress
        exchange.progressNotification(new ProgressNotification(progressToken, 0.0, 1.0, "Retrieving weather forecast"));

        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);

        String epicPoem = "MCP client doesn't provide sampling capability.";

        if (exchange.getClientCapabilities().sampling() != null) {

            // 50% progress
            exchange.progressNotification(new ProgressNotification(progressToken, 0.5, 1.0, "Start sampling"));

            String samplingMessage = """
                For a weather forecast (temperature is in Celsius): %s.
                At location with latitude: %s and longitude: %s.
                Please write an epic poem about this forecast using a Shakespearean style.
                """.formatted(weatherResponse.current().temperature_2m(), latitude, longitude);

            CreateMessageResult samplingResponse = exchange.createMessage(CreateMessageRequest.builder()
                    .systemPrompt("You are a poet!")
                    .messages(List.of(new SamplingMessage(Role.USER, new TextContent(samplingMessage))))
                    .modelPreferences(ModelPreferences.builder().addHint("anthropic").build())
                    .build());

            epicPoem = ((TextContent) samplingResponse.content()).text();

        }

        // 100% progress
        exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "Task completed"));

        return """
             Weather Poem2: %s
             about the weather: %s°C at location with latitude: %s and longitude: %s
             """.formatted(epicPoem, weatherResponse.current().temperature_2m(), latitude, longitude);
    }

}

DatabaseService.java

数据库查询工具

@Service
public class DatabaseService {

    private Connection connection;
    private final String jdbcUrl;
    private final String username;
    private final String password;

    public DatabaseService() {
        // 从环境变量读取数据库配置
        String host = "192.168.8.134";
        String port = "30635";
        String database = "test_1";
        this.username = "root";
        this.password = "xxxx";
        
        this.jdbcUrl = String.format("jdbc:mysql://%s:%s/%s?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC", 
                                   host, port, database);
        
        // 初始化数据库连接
        initializeConnection();
    }

    private String getEnvOrDefault(String key, String defaultValue) {
        String value = System.getenv(key);
        return value != null ? value : defaultValue;
    }

    private String getRequiredEnv(String key) {
        String value = System.getenv(key);
        if (value == null || value.trim().isEmpty()) {
            throw new IllegalArgumentException("Required environment variable " + key + " is not set");
        }
        return value;
    }

    private void initializeConnection() {
        try {
            Class.forName("com.mysql.cj.jdbc.Driver");
            this.connection = DriverManager.getConnection(jdbcUrl, username, password);
        } catch (ClassNotFoundException | SQLException e) {
            throw new RuntimeException("Failed to initialize database connection: " + e.getMessage(), e);
        }
    }

    private Connection getConnection() throws SQLException {
        if (connection == null || connection.isClosed()) {
            initializeConnection();
        }
        return connection;
    }

    @McpTool(description = "执行MySQL查询语句(SELECT)")
    public String executeQuery(McpSyncServerExchange exchange,
                              @McpToolParam(description = "要执行的SQL查询语句") String sql,
                              @McpProgressToken String progressToken) {

        exchange.loggingNotification(LoggingMessageNotification.builder()
                .level(LoggingLevel.DEBUG)
                .data("Executing query: " + sql)
                .meta(Map.of())
                .build());

        exchange.progressNotification(new ProgressNotification(progressToken, 0.0, 1.0, "开始执行查询"));

        try (Connection conn = getConnection();
             Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(sql)) {

            exchange.progressNotification(new ProgressNotification(progressToken, 0.5, 1.0, "处理查询结果"));

            ResultSetMetaData metaData = rs.getMetaData();
            int columnCount = metaData.getColumnCount();

            StringBuilder result = new StringBuilder();
            result.append("查询执行成功!\n\n");
            result.append("列信息:\n");
            for (int i = 1; i <= columnCount; i++) {
                result.append(String.format("  %s (%s)\n", 
                    metaData.getColumnName(i), 
                    metaData.getColumnTypeName(i)));
            }
            result.append("\n查询结果:\n");

            int rowCount = 0;
            while (rs.next()) {
                rowCount++;
                result.append("行 ").append(rowCount).append(": ");
                for (int i = 1; i <= columnCount; i++) {
                    Object value = rs.getObject(i);
                    result.append(metaData.getColumnName(i)).append("=").append(value);
                    if (i < columnCount) result.append(", ");
                }
                result.append("\n");
            }

            exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "查询完成"));
            
            if (rowCount == 0) {
                result.append("(查询未返回任何行)");
            } else {
                result.append("\n总计: ").append(rowCount).append(" 行");
            }

            return result.toString();

        } catch (SQLException e) {
            exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "查询失败"));
            return "查询执行失败: " + e.getMessage();
        }
    }

    @McpTool(description = "执行MySQL更新语句(INSERT, UPDATE, DELETE)")
    public String executeUpdate(McpSyncServerExchange exchange,
                               @McpToolParam(description = "要执行的SQL更新语句") String sql,
                               @McpProgressToken String progressToken) {

        exchange.loggingNotification(LoggingMessageNotification.builder()
                .level(LoggingLevel.DEBUG)
                .data("Executing update: " + sql)
                .meta(Map.of())
                .build());

        exchange.progressNotification(new ProgressNotification(progressToken, 0.0, 1.0, "开始执行更新"));

        try (Connection conn = getConnection();
             Statement stmt = conn.createStatement()) {

            exchange.progressNotification(new ProgressNotification(progressToken, 0.5, 1.0, "执行更新语句"));

            int affectedRows = stmt.executeUpdate(sql);

            exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "更新完成"));

            return String.format("更新执行成功! 影响了 %d 行。", affectedRows);

        } catch (SQLException e) {
            exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "更新失败"));
            return "更新执行失败: " + e.getMessage();
        }
    }

    @McpTool(description = "执行任意MySQL语句")
    public String executeSql(McpSyncServerExchange exchange,
                            @McpToolParam(description = "要执行的SQL语句") String sql,
                            @McpProgressToken String progressToken) {

        exchange.loggingNotification(LoggingMessageNotification.builder()
                .level(LoggingLevel.DEBUG)
                .data("Executing SQL: " + sql)
                .meta(Map.of())
                .build());

        exchange.progressNotification(new ProgressNotification(progressToken, 0.0, 1.0, "开始执行SQL"));

        try (Connection conn = getConnection();
             Statement stmt = conn.createStatement()) {

            exchange.progressNotification(new ProgressNotification(progressToken, 0.5, 1.0, "执行SQL语句"));

            boolean isResultSet = stmt.execute(sql);

            StringBuilder result = new StringBuilder();

            if (isResultSet) {
                // 处理查询结果
                try (ResultSet rs = stmt.getResultSet()) {
                    ResultSetMetaData metaData = rs.getMetaData();
                    int columnCount = metaData.getColumnCount();

                    result.append("SQL执行成功 - 查询结果:\n\n");
                    result.append("列信息:\n");
                    for (int i = 1; i <= columnCount; i++) {
                        result.append(String.format("  %s (%s)\n", 
                            metaData.getColumnName(i), 
                            metaData.getColumnTypeName(i)));
                    }
                    result.append("\n数据:\n");

                    int rowCount = 0;
                    while (rs.next()) {
                        rowCount++;
                        result.append("行 ").append(rowCount).append(": ");
                        for (int i = 1; i <= columnCount; i++) {
                            Object value = rs.getObject(i);
                            result.append(metaData.getColumnName(i)).append("=").append(value);
                            if (i < columnCount) result.append(", ");
                        }
                        result.append("\n");
                    }

                    if (rowCount == 0) {
                        result.append("(查询未返回任何行)");
                    } else {
                        result.append("\n总计: ").append(rowCount).append(" 行");
                    }
                }
            } else {
                // 处理更新结果
                int affectedRows = stmt.getUpdateCount();
                result.append("SQL执行成功 - 更新了 ").append(affectedRows).append(" 行。");
            }

            exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "SQL执行完成"));
            return result.toString();

        } catch (SQLException e) {
            exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "SQL执行失败"));
            return "SQL执行失败: " + e.getMessage();
        }
    }

    @McpTool(description = "获取数据库表列表")
    public String getTables(McpSyncServerExchange exchange,
                           @McpProgressToken String progressToken) {

        exchange.loggingNotification(LoggingMessageNotification.builder()
                .level(LoggingLevel.DEBUG)
                .data("Getting database tables")
                .meta(Map.of())
                .build());

        exchange.progressNotification(new ProgressNotification(progressToken, 0.0, 1.0, "获取表列表"));

        try {
            return executeQuery(exchange, "SHOW TABLES", progressToken);
        } catch (Exception e) {
            exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "获取表列表失败"));
            return "获取表列表失败: " + e.getMessage();
        }
    }

    @McpTool(description = "获取指定表的结构信息")
    public String getTableStructure(McpSyncServerExchange exchange,
                                  @McpToolParam(description = "表名") String tableName,
                                  @McpProgressToken String progressToken) {

        exchange.loggingNotification(LoggingMessageNotification.builder()
                .level(LoggingLevel.DEBUG)
                .data("Getting table structure: " + tableName)
                .meta(Map.of())
                .build());

        exchange.progressNotification(new ProgressNotification(progressToken, 0.0, 1.0, "获取表结构"));

        try {
            String sql = "DESCRIBE " + tableName;
            return executeQuery(exchange, sql, progressToken);
        } catch (Exception e) {
            exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "获取表结构失败"));
            return "获取表结构失败: " + e.getMessage();
        }
    }

    @McpTool(description = "测试数据库连接")
    public String testConnection(McpSyncServerExchange exchange,
                                @McpProgressToken String progressToken) {

        exchange.loggingNotification(LoggingMessageNotification.builder()
                .level(LoggingLevel.DEBUG)
                .data("Testing database connection")
                .meta(Map.of())
                .build());

        exchange.progressNotification(new ProgressNotification(progressToken, 0.5, 1.0, "测试连接"));

        try {
            Connection conn = getConnection();
            if (conn != null && !conn.isClosed()) {
                DatabaseMetaData metaData = conn.getMetaData();
                String result = String.format(
                    "数据库连接测试成功!\n" +
                    "数据库产品: %s\n" +
                    "数据库版本: %s\n" +
                    "驱动名称: %s\n" +
                    "驱动版本: %s\n" +
                    "连接URL: %s",
                    metaData.getDatabaseProductName(),
                    metaData.getDatabaseProductVersion(),
                    metaData.getDriverName(),
                    metaData.getDriverVersion(),
                    jdbcUrl
                );
                
                exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "连接测试完成"));
                return result;
            } else {
                exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "连接测试失败"));
                return "数据库连接失败: 连接无效或已关闭";
            }
        } catch (SQLException e) {
            exchange.progressNotification(new ProgressNotification(progressToken, 1.0, 1.0, "连接测试失败"));
            return "数据库连接测试失败: " + e.getMessage();
        }
    }
}

启动日志

image.png

客户端配置MCP

{
  "mcpServers": {
    "天气服务": {
      "disabled": false,
      "disabledTools": [],
      "url": "http://localhost:8080/sse"
    }
  }
}

image.png

  • 然后就可以愉快使用了