こんにちは!ソリューション開発部の片岡です。 普段は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では、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.ktsにimplementation("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でログを確認すると、次のようなやり取りが行われています。
- LLMに対して初回リクエスト
- LLMがツール呼び出しを要求するレスポンスを返す
- LLMに対して2回目のリクエスト(ツール実行結果を含む)
- 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で、ツール呼び出しの機能を利用する際、特にオプションを指定しない場合は内部的にツール呼び出しが解決されます。
// 内容一部省略 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 AI#フレームワーク制御ツール実行
- そのため、先ほど確認したような実装を独自で実装する必要がある
基本的な低レベルのインターフェースは用意されているため、所々独自実装が必要になる場合もありますが、Spring BootでLLMやMCPを使った機能を実装する際の選択肢としては、 十分な機能を具備していると感じました。
おわりに
当社では、AI・IoT・ロボティクスの力で社会の課題を解決していくエンジニアを募集しています!技術的な挑戦を楽しみながら、一緒にプロダクトを成長させていける仲間を募集していますので、ご興味のある方は、ぜひご連絡ください。