【Kotlin/Spring Boot】LLM×外部ツール連携をSpring AIで実現!MCP実装のガイド

こんにちは!ソリューション開発部の片岡です。 普段はKotlinを使ったバックエンド開発を行っています。

今回はMCP(Model Context Protocol)のサーバーとクライアントをKotlinで実装する機会があったため、その知見を共有したいと思います。

本記事は OPTiM TECH BLOG Advent Calendar 2025 Day 18 の記事です。

MCPとは

MCP(Model Context Protocol)は、Anthropicが提唱するLLMアプリケーションとデータソース・ツールを接続するための標準プロトコルです。

  • LLMが外部ツールやデータにアクセスするための統一的なインターフェース
  • クライアント(Claude Desktopなど)とサーバー間でJSON-RPCを用いて通信
  • ツールの実行、リソースの提供、プロンプトテンプレートなどの機能を提供

今回は外部のデータソースに対してMCPサーバー・MCPクライアントを実装します。

KotlinにおけるMCPサーバーの選択肢

Kotlinでライブラリを使用し、MCPサーバーを実装する場合、以下のような選択肢があります。

1. Spring AI

  • AIに関する機能群を持ったSpringのプロジェクト
  • 様々なAIモデルプロバイダーに対するインターフェースを有する
  • Javaで作られている

2. Koog

  • JetBrains公式のAIエージェントのためのフレームワーク
  • 様々なAIモデルプロバイダーのLLMに対応していて、AIエージェントやワークフローを構築可能
  • Kotlinで作られている

3. LangChain4j

  • Python向けのLLMライブラリであるLangChainを元に作られたライブラリ
  • 様々なAIモデルプロバイダーのLLMに対応していて、AIエージェントやワークフローを構築可能
  • Javaで作られている

今回は、Spring Bootで作られたWebサーバーに組み込むことを目的としており、処理を細かくカスタマイズしたいという目的に合っていたSpring AIを利用しました。

Spring AIでMCPサーバーの構築

まずは、MCPサーバーを構築します。

サーバーのプロジェクト作成

Spring Initializrを利用します。

  • Spring Web
  • Model Context Protocol Server

を依存関係として追加して、プロジェクトを作成します。

build.gradle.ktsを確認すると、Spring AIのパッケージが追加されています。

// build.gradle.kts
dependencies {
  implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc")
  // 省略
}

ツールの実装

Spring AI#Tools

Spring AIでは、ToolCallbackProviderとそこに登録する@Toolが付与された関数群を持つServiceクラスを実装することで、 MCPサーバーとして外部から利用可能なツールを提供できます。

// kotlin/com/example/server/TaskService.kt
package com.example.server

import org.springframework.ai.tool.annotation.Tool
import org.springframework.ai.tool.annotation.ToolParam
import org.springframework.stereotype.Service

@Service
class TaskService{
    @Tool(description = "タスクの一覧を返します")
    fun getTasks(
        @ToolParam(required = true, description = "ユーザーID")
        userId: String
    ): String {
        // タスクの一覧を取得する処理
        val tasks = """
            [
                {
                    "id": 1,
                    "name": "サンプル1"
                },
                {
                    "id": 2,
                    "name": "サンプル2"
                }
            ]
        """.trimIndent()
        return tasks
    }
}
  • @Toolを付与した関数が利用者側に見える関数となる
  • descriptionとして説明を渡すことができ、LLMにどのような関数なのかを説明することができる
  • 引数には任意で@ToolParamを渡すことができ、引数の説明や必須かどうかを指定することができる

作成したServiceはToolCallbackProviderを返すBeanとして登録する

// kotlin/com/example/server/ToolProvider.kt
package com.example.server

import org.springframework.ai.tool.ToolCallbackProvider
import org.springframework.ai.tool.method.MethodToolCallbackProvider
import org.springframework.context.annotation.Bean
import org.springframework.stereotype.Component

@Component
class ToolProvider {
    @Bean
    fun taskTools(taskService: TaskService): ToolCallbackProvider =
        MethodToolCallbackProvider.builder().toolObjects(taskService).build()
}

最後に、application.propertiesを削除し、代わりにapplication.ymlを作成して設定値を書き込みます。

server:
  port: 8081
spring:
  application:
    name: server
  ai:
    mcp:
      server:
        name: server
        version: 1.0.0
        type: SYNC
        protocol: STREAMABLE
        instructions: "タスク管理をするサーバー"

protocol: STREAMABLEを指定することで、最新の通信形式を利用できます。(mcp#streamable-http)

Spring AIでMCPクライアントの構築

次に、MCPクライアントを構築します。

クライアントのプロジェクト作成

MCPサーバーで作成した設定の、Model Context Protocol Serverの依存関係をClientに変更します。

クライアントの作成

Spring AIでは、様々なLLMに対して統一されたインターフェースでアクセスできます。 今回は、OpenAIのAPIを利用してクライアントを作成します。

そのため、build.gradle.ktsimplementation("org.springframework.ai:spring-ai-starter-model-openai")を追加します。

dependencies {
  // 追加
    implementation("org.springframework.ai:spring-ai-starter-model-openai")
}

MCPサーバー・LLMとの通信の設定

LLMのAPIを実行する処理を実装します。

// kotlin/com/example/client/LlmProvider.kt
package com.example.client

import org.springframework.ai.chat.client.ChatClient
import org.springframework.ai.chat.messages.AssistantMessage
import org.springframework.ai.chat.messages.SystemMessage
import org.springframework.ai.chat.messages.ToolResponseMessage
import org.springframework.ai.chat.messages.UserMessage
import org.springframework.ai.chat.prompt.Prompt
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider
import org.springframework.ai.openai.OpenAiChatModel
import org.springframework.ai.openai.OpenAiChatOptions
import org.springframework.ai.openai.api.OpenAiApi
import org.springframework.stereotype.Repository

@Repository
class LlmProvider(
    private val openAiApi: OpenAiApi,
    // Beanで、Spring AIが自動構成するMCPツール呼び出し用のインスタンスを受け取る
    private val toolCallbackProvider: SyncMcpToolCallbackProvider,
) {
    fun chat(): String {
        // 使用するモデルを指定
        val modelName = "xxx"
        // ツール呼び出し可能なチャットのオプションを構築
        val chatOptions = OpenAiChatOptions
            .builder()
            .model(modelName)
            .toolCallbacks(toolCallbackProvider.toolCallbacks.toMutableList())
            .build()

        val chatClient = ChatClient.builder(
            OpenAiChatModel
                .builder()
                .openAiApi(openAiApi)
                .build()
        ).build()

        val content =
            chatClient
                .prompt(
                    Prompt(
                        listOf(
                            // システムプロンプトの指定
                            SystemMessage(
                                "ユーザーの命令に従って結果を返してください。"
                            ),
                            UserMessage(
                                "ユーザーID 1のタスクの一覧を取得してください。"
                            )
                        ),
                        chatOptions,
                    )
                )
                .call() // 同期的に実行(SSEでStreamingする方法も存在する)
                .content() // 結果の文字列のみ取得

        return content.orEmpty()
    }
}

では、実行のためにコントローラーを作成します。

// kotlin/com/example/client/TestsController.kt
package com.example.client

import org.springframework.http.MediaType
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseBody

@Controller
@RequestMapping(
    "/tests",
)
class TestsController(
    private val llmProvider: LlmProvider,
) {
    /**
     * LLMを呼ぶだけの簡素なエンドポイント
     */
    @GetMapping
    @ResponseBody
    fun chat(): String {
        return llmProvider.chat()
    }
}

最後に、application.propertiesを削除し、代わりにapplication.ymlを作成して設定値を書き込みます。

spring:
  application:
    name: client
  ai:
    # OpenAIに接続する設定
    openai:
      api-key: "XXXX"
      base-url: "XXXX"
    # MCPサーバーに接続する設定
    mcp:
      client:
        type: SYNC
        protocol: STREAMABLE
        version: 1.0.0
        streamable-http:
          connections:
            server-mcp: # MCPサーバーを表す任意の名前
              url: "http://localhost:8081"

以上で最低限のMCPサーバー・MCPクライアントの実装は完了です。

MCPサーバーの呼び出し

では実際にMCPサーバーを呼び出してみましょう。 内部的にどのような動きをしているか確認するために、LiteLLMを使用します。

LiteLLMは、OpenAI、Anthropicなどの異なるLLMプロバイダーのAPIの違いを吸収し、OpenAI形式の統一されたインターフェースでLLMを呼び出せるライブラリです。

ロードバランシングやコスト追跡などさまざまな機能を持ちますが、今回は実際にどのようなリクエスト・レスポンスがやり取りされているのかを確認するために利用します。 (その為、今回は構築方法等は省略します。)

# 実行
curl localhost:8080/tests

結果は次のとおりです。

ユーザーID 1のタスク一覧を取得しました。以下のタスクがあります:
1. **タスクID 1**: サンプル1
2. **タスクID 2**: サンプル2

ツール呼び出しが行われ、タスク一覧を取得できています!

LiteLLMでログを確認すると、次のようなやり取りが行われています。

  1. LLMに対して初回リクエスト
  2. LLMがツール呼び出しを要求するレスポンスを返す
  3. LLMに対して2回目のリクエスト(ツール実行結果を含む)
  4. LLMが最終的な結果を返す

1. LLMに対して初回リクエスト

リクエストの際、クライアントは事前にMCPサーバーから使用可能なツール情報を取得し、それをLLMに渡します。 @Tool@ToolParamで指定した説明や、Spring AIが自動的に型や関数名から取得する情報がLLMに渡されていることがわかります。

// 結果は一部省略
{
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "getTasks",
        "parameters": {
          "type": "object",
          "required": [
            "userId"
          ],
          "properties": {
            "userId": {
              "type": "string",
              "description": "ユーザーID"
            }
          },
          "additionalProperties": false
        },
        "description": "タスクの一覧を返します"
      }
    }
  ],
  "messages": [
    {
      "role": "system",
      "content": "ユーザーの命令に従って結果を返してください。"
    },
    {
      "role": "user",
      "content": "ユーザーID 1のタスクの一覧を取得してください。"
    }
  ],
}

2. LLMがツール呼び出しを要求するレスポンスを返す

LLMは、ユーザープロンプトを読み、ツール実行が必要と判断します。 そこで、レスポンスとしてtool_callsを含むレスポンスを返します。

tool_callsには、どのツールをどのような引数で呼び出すのかが記載されています。 また、finish_reasonとしてもtool_callsが設定されています。

// 結果は一部省略
{
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "ユーザーID 1のタスク一覧を取得します。",
        "tool_calls": [
          {
            "id": "tooluse_XfSzDfh2QVWCTTrnV4xtjA",
            "type": "function",
            "index": 1,
            "function": {
              "name": "getTasks",
              "arguments": "{\"userId\": \"1\"}"
            }
          }
        ],
        "function_call": null
      },
      "finish_reason": "tool_calls"
    }
  ],
}

3. LLMに対して2回目のリクエスト(ツール実行結果を含む)

MCPクライアントは2の結果を受け取った後、MCPサーバーに対してツールを呼び出し、その結果を含めて再度リクエストを行います。

LLMに対するリクエストは、ステートレス、状態を維持しないため、最初のシステムプロンプト・ユーザープロンプト・LLMのレスポンスを含めて2回目のリクエストを行います。 tool_callsで指定されていたidを紐づけるようにtool_call_idが指定されていることがわかります。

// 結果は一部省略
{
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "getTasks",
        "parameters": {
          "type": "object",
          "required": [
            "userId"
          ],
          "properties": {
            "userId": {
              "type": "string",
              "description": "ユーザーID"
            }
          },
          "additionalProperties": false
        },
        "description": "タスクの一覧を返します"
      }
    }
  ],
  "messages": [
    {
      "role": "system",
      "content": "ユーザーの命令に従って結果を返してください。"
    },
    {
      "role": "user",
      "content": "ユーザーID 1のタスクの一覧を取得してください。"
    },
    {
      "role": "assistant",
      "content": "ユーザーID 1のタスク一覧を取得します。",
      "tool_calls": [
        {
          "id": "tooluse_XfSzDfh2QVWCTTrnV4xtjA",
          "type": "function",
          "function": {
            "name": "getTasks",
            "arguments": "{\"userId\": \"1\"}"
          }
        }
      ]
    },
    {
      "name": "getTasks",
      "role": "tool",
      "content": "[{\"text\":\"[\\n    {\\n        \\\"id\\\": 1,\\n        \\\"name\\\": \\\"サンプル1\\\"\\n    },\\n    {\\n        \\\"id\\\": 2,\\n        \\\"name\\\": \\\"サンプル2\\\"\\n    }\\n]\"}]",
      "tool_call_id": "tooluse_XfSzDfh2QVWCTTrnV4xtjA"
    }
  ],
}

4. LLMが最終的な結果を返す

最後に、LLMがユーザーに返すべき結果を返します。 contentを見ると、リクエストの結果返ってきているものと同じであることがわかります。

// 結果は一部省略
{
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "ユーザーID 1のタスク一覧を取得しました。以下のタスクがあります:\n\n1. **タスクID 1**: サンプル1\n2. **タスクID 2**: サンプル2\n\n合計2つのタスクが登録されています。",
        "tool_calls": null,
        "function_call": null
      },
      "finish_reason": "stop"
    }
  ],
}

Spring AIのツール呼び出しの流れ

Spring AIで、ツール呼び出しの機能を利用する際、特にオプションを指定しない場合は内部的にツール呼び出しが解決されます。

ToolCallAdvisor.java

// 内容一部省略
public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
    do {
        var processedChatClientRequest = ChatClientRequest.builder()
            .prompt(new Prompt(instructions, optionsCopy))
            .context(chatClientRequest.context())
            .build();

        chatClientResponse = callAdvisorChain.copy(this).nextCall(processedChatClientRequest);

        isToolCall = chatClientResponse.chatResponse() != null && chatClientResponse.chatResponse().hasToolCalls();

        if (isToolCall) {
            ToolExecutionResult toolExecutionResult = this.toolCallingManager
                .executeToolCalls(processedChatClientRequest.prompt(), chatClientResponse.chatResponse());

            instructions = toolExecutionResult.conversationHistory();
        }

    }
    while (isToolCall); // loop until no tool calls are present

    return chatClientResponse;
}

Spring AIのコードを読んでみると、while (isToolCall)のループによって、ツール呼び出しが存在する限り、 繰り返し処理が実行されることがわかります。

conversationHistory()といったメソッドを通じて、ツール呼び出し後の結果が会話履歴に追加され、次のLLMへのリクエストに含まれる仕組みになっています。

このように、Spring AIではツール呼び出しに関して面倒な複数回に渡るLLMとのやり取りを内部に隠蔽していることが分かりました。 開発者は単純にツールを定義して登録するだけで、複雑なツール呼び出しフローを意識することなく機能を利用できます。

まとめ

Spring AIを使用する場合、次のような点で簡単に実装が行えました。

  • ツール呼び出しの抽象化
  • MCPサーバーとの通信処理
  • LLMに対する通信処理

ただし、執筆時点ではバージョン 1.1.0が最新となっており、まだまだ機能が不足している状況です。

基本的な低レベルのインターフェースは用意されているため、所々独自実装が必要になる場合もありますが、Spring BootでLLMやMCPを使った機能を実装する際の選択肢としては、 十分な機能を具備していると感じました。

おわりに

当社では、AI・IoT・ロボティクスの力で社会の課題を解決していくエンジニアを募集しています!技術的な挑戦を楽しみながら、一緒にプロダクトを成長させていける仲間を募集していますので、ご興味のある方は、ぜひご連絡ください。

www.optim.co.jp