【MCP】Spring AIのサンプルプロジェクトを参考にMCPサーバを作成する

# 初めに
流行りのMCPサーバを構築したかったのですが、普段書いてるPHPのSDKはありませんでした。
代わりに、Spring AIのサンプルプロジェクトを参考作成してみたので、備忘としてまとめます。


# 参考にしたサンプルプロジェクト

サンプルプロジェクトはこちら。

https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/starter-stdio-server

公式ドキュメントはこちら。

https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html

# 作成するMCPサーバの種類
MCPサーバにはLLMに提供する情報の違いによっていくつか種類があります（[参考](https://modelcontextprotocol.io/specification/2025-03-26#overview)）

| MCPサーバの種類 | 詳細 |
|:-:|:-:|
|  Resources | LLM Hostに提供するドキュメントなどの情報  |
| Tool  |  APIなど、LLM Hostが実行できるツール |
| Prompts  | LLMとの会話テンプレート  |


また、Spring AIを用いてMCPサーバには、クライアントとサーバ間の情報の受け渡し方にもいくつか種類があります（[参考](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html#_transport_options)）

| 情報の受け渡し方法 | 詳細 |
|:-:|:-:|
|  STDIO | 標準入出力を用いたデータの受け渡し  |
| Spring MVC  |  Spring MVCを用いたServer-Sent Events (SSE) 通信 |
| Spring WebFlux  | 同じくSSEを用いるが、非同期通信  |


今回は一番簡単そうな**Tool**と**STDIO**で実装しました。

# 環境
**Java: 21**
**gradle: 8.13**
**SpringBoot: 3.4.4**

# MCPサーバ構築する
それでは構築していきましょう。
今回作成するMCPサーバは「アメリカの気象情報を取得する」MCPサーバです。サンプルプロジェクトのものをそのまま利用しました。

アメリカの国立気象局（NWS: National Weather Service）が提供している天気予報APIを呼び出しています。



## 0. ディレクトリ構成
今回のディレクトリ構成です。
元となるプロジェクトは[Spring Initializr](https://start.spring.io/)を使って構築しています。
DependenciesにSpring Webを追加しました。

```text: ディレクトリ構成
Project
├── src/
|   ├── main/
│   ├── java/
│   │   └── com/mcp/weather/
│   │       ├── service/
|   |       |   └── WeatherService.java  #ツールを定義するクラス
│   │       └── McpApplication.java      #メインクラス
│   └── resources/
│       └── application.properties       #設定
└── build.gradle
```

## 1. build.gradle
Spring AIを追加します。
`Spring Initializr`では指定できなかったので、ここで直書きしました。

サンプルプロジェクトはmavenなので、ここが唯一の違いかも。

```gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '3.4.4'
	id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.mcp'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(21)
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.ai:spring-ai-mcp-server-spring-boot-starter:1.0.0-M6'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
	useJUnitPlatform()
}
```

## 2. McpApplication.java
メインクラスです。
`ToolCallbackProvider`を返すメソッドを定義します。

`ToolCallbackProvider`はMCPが利用できるツールを定義するためのクラスのようです。
後述するServiceクラスではツールを定義しており、その一つ一つを定義するみたいですね。

```java
package com.mcp.weather;

import com.mcp.weather.service.WeatherService;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class McpApplication {

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

	@Bean
	public ToolCallbackProvider weatherTools(WeatherService weatherService) {
		return  MethodToolCallbackProvider.builder().toolObjects(weatherService).build();
	}
}

```

## 3. WeatherService.java
ツールを定義するクラスです。
長いので一部抜粋です。

`@Tool`をつけることでMCPにツールと認識させることができます。
`description`はツールの説明です。LLMはここを見て呼び出すAPIを決定します。

```java
package com.mcp.weather.service;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

@Service
public class WeatherService {

    // 長いため一部抜粋

    /**
     * 天気を取得するツール
     * 
     * @param latitude Latitude
     * @param longitude Longitude
     * @return The forecast for the given location
     * @throws RestClientException if the request fails
     */
    @Tool(description = "Get weather forecast for a specific latitude/longitude")
    public String getWeatherForecastByLocation(double latitude, double longitude) {

        var points = restClient.get()
                .uri("/points/{latitude},{longitude}", latitude, longitude)
                .retrieve()
                .body(Points.class);

        var forecast = restClient.get().uri(points.properties().forecast()).retrieve().body(Forecast.class);

        String forecastText = forecast.properties().periods().stream().map(p -> {
            return String.format("""
					%s:
					Temperature: %s %s
					Wind: %s %s
					Forecast: %s
					""", p.name(), p.temperature(), p.temperatureUnit(), p.windSpeed(), p.windDirection(),
                    p.detailedForecast());
        }).collect(Collectors.joining());

        return forecastText;
    }

    /**
     * アラート情報を取得するツール
     * 
     * @param state Area code. Two-letter US state code (e.g. CA, NY)
     * @return Human readable alert information
     * @throws RestClientException if the request fails
     */
    @Tool(description = "Get weather alerts for a US state. Input is Two-letter US state code (e.g. CA, NY)")
    public String getAlerts(@ToolParam( description =  "Two-letter US state code (e.g. CA, NY") String state) {
        Alert alert = restClient.get().uri("/alerts/active/area/{state}", state).retrieve().body(Alert.class);

        return alert.features()
                .stream()
                .map(f -> String.format("""
					Event: %s
					Area: %s
					Severity: %s
					Description: %s
					Instructions: %s
					""", f.properties().event(), f.properties.areaDesc(), f.properties.severity(),
                        f.properties.description(), f.properties.instruction()))
                .collect(Collectors.joining("\n"));
    }
```

<details><summary>WeatherService.javaの全量はこちら</summary>

```java
package com.mcp.weather.service;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

@Service
public class WeatherService {

    private static final String BASE_URL = "https://api.weather.gov";

    private final RestClient restClient;

    public WeatherService() {

        this.restClient = RestClient.builder()
                .baseUrl(BASE_URL)
                .defaultHeader("Accept", "application/geo+json")
                .defaultHeader("User-Agent", "WeatherApiClient/1.0 (your@email.com)")
                .build();
    }

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Points(@JsonProperty("properties") Props properties) {
        @JsonIgnoreProperties(ignoreUnknown = true)
        public record Props(@JsonProperty("forecast") String forecast) {
        }
    }

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Forecast(@JsonProperty("properties") Props properties) {
        @JsonIgnoreProperties(ignoreUnknown = true)
        public record Props(@JsonProperty("periods") List<Period> periods) {
        }

        @JsonIgnoreProperties(ignoreUnknown = true)
        public record Period(@JsonProperty("number") Integer number, @JsonProperty("name") String name,
                             @JsonProperty("startTime") String startTime, @JsonProperty("endTime") String endTime,
                             @JsonProperty("isDaytime") Boolean isDayTime, @JsonProperty("temperature") Integer temperature,
                             @JsonProperty("temperatureUnit") String temperatureUnit,
                             @JsonProperty("temperatureTrend") String temperatureTrend,
                             @JsonProperty("probabilityOfPrecipitation") Map probabilityOfPrecipitation,
                             @JsonProperty("windSpeed") String windSpeed, @JsonProperty("windDirection") String windDirection,
                             @JsonProperty("icon") String icon, @JsonProperty("shortForecast") String shortForecast,
                             @JsonProperty("detailedForecast") String detailedForecast) {
        }
    }

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Alert(@JsonProperty("features") List<Feature> features) {

        @JsonIgnoreProperties(ignoreUnknown = true)
        public record Feature(@JsonProperty("properties") Properties properties) {
        }

        @JsonIgnoreProperties(ignoreUnknown = true)
        public record Properties(@JsonProperty("event") String event, @JsonProperty("areaDesc") String areaDesc,
                                 @JsonProperty("severity") String severity, @JsonProperty("description") String description,
                                 @JsonProperty("instruction") String instruction) {
        }
    }

    /**
     * Get forecast for a specific latitude/longitude
     * @param latitude Latitude
     * @param longitude Longitude
     * @return The forecast for the given location
     * @throws RestClientException if the request fails
     */
    @Tool(description = "Get weather forecast for a specific latitude/longitude")
    public String getWeatherForecastByLocation(double latitude, double longitude) {

        var points = restClient.get()
                .uri("/points/{latitude},{longitude}", latitude, longitude)
                .retrieve()
                .body(Points.class);

        var forecast = restClient.get().uri(points.properties().forecast()).retrieve().body(Forecast.class);

        String forecastText = forecast.properties().periods().stream().map(p -> {
            return String.format("""
					%s:
					Temperature: %s %s
					Wind: %s %s
					Forecast: %s
					""", p.name(), p.temperature(), p.temperatureUnit(), p.windSpeed(), p.windDirection(),
                    p.detailedForecast());
        }).collect(Collectors.joining());

        return forecastText;
    }

    /**
     * Get alerts for a specific area
     * @param state Area code. Two-letter US state code (e.g. CA, NY)
     * @return Human readable alert information
     * @throws RestClientException if the request fails
     */
    @Tool(description = "Get weather alerts for a US state. Input is Two-letter US state code (e.g. CA, NY)")
    public String getAlerts(@ToolParam( description =  "Two-letter US state code (e.g. CA, NY") String state) {
        Alert alert = restClient.get().uri("/alerts/active/area/{state}", state).retrieve().body(Alert.class);

        return alert.features()
                .stream()
                .map(f -> String.format("""
					Event: %s
					Area: %s
					Severity: %s
					Description: %s
					Instructions: %s
					""", f.properties().event(), f.properties.areaDesc(), f.properties.severity(),
                        f.properties.description(), f.properties.instruction()))
                .collect(Collectors.joining("\n"));
    }

    public static void main(String[] args) {
        WeatherService client = new WeatherService();
        System.out.println(client.getWeatherForecastByLocation(47.6062, -122.3321));
        System.out.println(client.getAlerts("LA"));
    }
}

```
</details>

## 4. application.properties
アプリケーションプロパティの設定です。
`spring.ai.mcp.server.stdio=true`でSTDIOを指定しています。
各設定値は[公式ドキュメント](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html#_configuration_properties)をご参照ください。

```properties: application.properties
spring.application.name=mcp-weather

# Required STDIO Configuration
spring.main.web-application-type=none
spring.main.banner-mode=off
logging.pattern.console=

# Server Configuration
spring.ai.mcp.server.enabled=true
spring.ai.mcp.server.stdio=true
spring.ai.mcp.server.name=my-weather-server
spring.ai.mcp.server.version=0.0.1
# SYNC or ASYNC
spring.ai.mcp.server.type=SYNC
spring.ai.mcp.server.resource-change-notification=true
spring.ai.mcp.server.tool-change-notification=true
spring.ai.mcp.server.prompt-change-notification=true

# Optional file logging
logging.file.name=mcp-weather-stdio-server.log
```

これでOKです。
buildしてjarファイルを作成します。

# Claude Desktopから呼び出す
作成したMCPサーバを呼び出してみましょう。
Claudeの設定ファイルに以下のように記述しました。

```json: 設定ファイル
{
    "mcpServers": {
      "weather": {
        "command": "java",
          "args": [
          "-jar",
          "C:\\Path\\to\\Project\\build\\libs\\mcp-weather-0.0.1-SNAPSHOT.jar"
        ]
      }
    }
}
```

再起動してみたところ、無事にMCPサーバが起動しました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430841/ebeb5991-f123-4424-81ce-5e55863e42e0.png)


# 終わりに
今回はSpring AIのサンプルプロジェクトを参考にMCPサーバを作成してみました。
サクッと実装できてお手軽でよかったです。
MCPサーバを提供できるとできることが広がるので、いろいろ活用していきたいですね。

ここまでご覧いただきありがとうございました！