JavaとSpring Bootで最初のMCPサーバーを書く
1. 概要
Model Context Protocol(MCP)は、AI assistant(Claude、Copilotなど)がツールを呼び出し、あなたのシステムからリソースを構造化された方法で読み取るための通信標準です。このガイドは、Java 21を使用してMCPサーバーを書き、注文検索ツール(lookup_order)を公開し、stdio transportを通してMCPクライアントと接続する方法を説明します。完了後には、実際に動作するMCPサーバーを構築でき、ツールハンドラーのテストを書くことができ、初期段階で一般的なエラーを知ることができます。
2. MCPとは何か、いつサーバーを書く必要があるのか
MCPは、サーバーが公開できる3つの主な概念を定義しています:
- Tool:AI modelが呼び出せる関数で、入力スキーマが明確に定義されている(function callingに似ている)。
- Resource:クライアントが読み取れる静的または動的データ(ファイル、設定、DBレコードなど)。
- Prompt:クライアントが再利用するための事前構築されたプロンプトテンプレート。
内部システムに対して直接AI assistantに操作させたい場合、MCPサーバーを書く必要があります。例えば、注文検索、チケット作成、ログクエリなど—手動でデータをチャットにコピーペーストする必要がなくなります。
注意: 静的なテキストに基づくモデルの回答が必要なだけの場合、RAGまたは通常のプロンプトを使用する方が、MCPサーバー全体を構築するよりもはるかに簡単です。
3. プロジェクトの準備
3.1. Maven依存関係
MCP Java SDKは、サーバーとtransportを構築するためのクラスを提供しています。次の依存関係をpom.xmlに追加してください:
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.8.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
Jacksonは、MCP内部で使用するJSON-RPCメッセージのシリアル化/デシリアル化に使用されます。
3.2. シンプルなドメインモデル
ツールを書く前に、ツールが返すデータが必要です。シミュレートされたOrderを使用します:
public record Order(String id, String status, double total) {
}
@Service
public class OrderService {
private final Map<String, Order> orders = Map.of(
"ORD-001", new Order("ORD-001", "SHIPPED", 450_000),
"ORD-002", new Order("ORD-002", "PENDING", 120_000)
);
public Optional<Order> findById(String orderId) {
return Optional.ofNullable(orders.get(orderId));
}
}
OrderServiceはデモンストレーション用のメモリ内マップに過ぎません。実際のプロジェクトでは、リポジトリまたはAPIを呼び出します。
4. 最初のツール定義:lookup_order
MCPの各ツールには、名前、説明(modelがツールを呼び出すかどうかを決定するために説明を使用)、およびJSON Schema形式の入力スキーマが必要です。
public final class OrderTools {
public static final McpSchema.Tool LOOKUP_ORDER = new McpSchema.Tool(
"lookup_order",
"注文IDに基づいて注文のステータスと合計金額を検索します",
"""
{
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "注文コード、例:ORD-001"
}
},
"required": ["orderId"]
}
"""
);
}
"lookup_order"はツール名で、クライアントはこの正確な文字列を使用してツールを呼び出します。- 2番目の説明文は非常に重要です:modelはこれに基づいてこのツールを他のツールの代わりに呼び出すかどうかを判断します。
- スキーマは
orderIdを必須フィールド、string型として指定します。
次に、ツールを実行するハンドラーを書きます。パラメータを受け取り、OrderServiceを呼び出し、CallToolResultを返します:
@Component
public class OrderToolHandler {
private final OrderService orderService;
public OrderToolHandler(OrderService orderService) {
this.orderService = orderService;
}
public McpSchema.CallToolResult handle(Map<String, Object> arguments) {
String orderId = (String) arguments.get("orderId");
return orderService.findById(orderId)
.map(order -> success(order))
.orElseGet(() -> error("注文が見つかりません: " + orderId));
}
private McpSchema.CallToolResult success(Order order) {
String text = "注文 %s: ステータス %s, 合計 %.0f 円"
.formatted(order.id(), order.status(), order.total());
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(text)), false);
}
private McpSchema.CallToolResult error(String message) {
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(message)), true);
}
}
CallToolResultの2番目のパラメータはisErrorです。trueの場合、クライアントはツールが失敗したことを知り、modelに報告して別の方法を試すことができます。有効な結果として扱うのではなく、異なる処理を必要とする重要な信号です。
5. stdio transportでMCPサーバーを初期化
新しくMCP serverを書く際に最も一般的なtransportはstdioです。サーバーがstdinからリクエストを読み取り、stdoutにレスポンスを書き込みます。これはClaude Desktopがローカルサーバーを起動する方法でもあります。
@Component
public class McpOrderServer implements CommandLineRunner {
private final OrderToolHandler toolHandler;
public McpOrderServer(OrderToolHandler toolHandler) {
this.toolHandler = toolHandler;
}
@Override
public void run(String... args) {
McpServerTransportProvider transportProvider =
new StdioServerTransportProvider(new ObjectMapper());
McpServerFeatures.SyncToolSpecification lookupOrderSpec =
new McpServerFeatures.SyncToolSpecification(
OrderTools.LOOKUP_ORDER,
(exchange, arguments) -> toolHandler.handle(arguments)
);
McpSyncServer server = McpServer.sync(transportProvider)
.serverInfo("nonla-order-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder()
.tools(true)
.build())
.tools(lookupOrderSpec)
.build();
server.start();
}
}
StdioServerTransportProviderはstdin/stdoutをJSON-RPC通信チャネルに変えます。serverInfo(...)は名前とバージョンを宣言します。接続されたサーバーをリスト表示するときに、クライアントはこの情報を表示します。capabilities().tools(true)はサーバーがツールを公開していることをクライアントに知らせます。クライアントはtools/listを送信して一覧を取得します。server.start()はプロセスをブロックして、クライアントからのstdin経由のリクエストを待ちます。
注意: stdio transportはブロッキングでプロセスを生かすため、通常のSpring Boot webアプリ(webサーバー付きの
@SpringBootApplication)で実行する場合、webサーバーを無効にする必要があります(application.propertiesでspring.main.web-application-type=none)ポートの競合を避け、ログがstdoutに混在しないようにします。
spring.main.web-application-type=none
spring.main.banner-mode=off
logging.pattern.console=
Bannerとconsoleログは完全に無効にする必要があります。MCPはstdoutをデータ伝送チャネルとして使用しており、任意のログラインが混在すると、JSON-RPCメッセージが破損します。
6. リソースの追加:静的データの公開
ツール以外に、クライアントが直接読み取れるリソースを公開できます(modelを通じて呼び出す必要がない):
McpSchema.Resource policyResource = new McpSchema.Resource(
"file:///policies/return-policy.md",
"返品ポリシー",
"商品返品ポリシーの内容",
"text/markdown",
null
);
McpServerFeatures.SyncResourceSpecification policySpec =
new McpServerFeatures.SyncResourceSpecification(
policyResource,
(exchange, request) -> new McpSchema.ReadResourceResult(
List.of(new McpSchema.TextResourceContents(
policyResource.uri(),
"text/markdown",
"# 返品ポリシー\n顧客は7日以内に返品できます。"
))
)
);
ステップ5のbuilderに.resources(policySpec)を追加して、サーバーはこのリソースを公開します。
7. Claude Desktopで試す
jarをビルドして、Claude Desktop(またはその他のMCPクライアント)を設定してサーバーを指します:
{
"mcpServers": {
"nonla-order-server": {
"command": "java",
"args": ["-jar", "/path/to/order-mcp-server.jar"]
}
}
}
クライアントを再起動し、新しい会話を開いて、質問してください:「注文ORD-001の状態は?」。modelは自動的にlookup_orderをorderId = "ORD-001"で呼び出すことを決定し、サーバーはテキストを返し、modelがその結果を使用して返信します。
8. ツールハンドラーのテスト
最も重要なロジック部分—パラメータ処理とCallToolResultの返却—は、実のtransportなしで通常のJavaコードのようにテストできます。
class OrderToolHandlerTest {
private final OrderService orderService = new OrderService();
private final OrderToolHandler handler = new OrderToolHandler(orderService);
@Test
void shouldReturnOrderInfo_whenOrderExists() {
var result = handler.handle(Map.of("orderId", "ORD-001"));
assertThat(result.isError()).isFalse();
assertThat(result.content())
.hasSize(1)
.first()
.isInstanceOfSatisfying(McpSchema.TextContent.class,
content -> assertThat(content.text()).contains("SHIPPED"));
}
@Test
void shouldReturnError_whenOrderNotFound() {
var result = handler.handle(Map.of("orderId", "ORD-999"));
assertThat(result.isError()).isTrue();
}
}
最初のテストは有効な注文をチェックします:isError()はfalseである必要があり、テキスト内容にはSHIPPEDステータスが含まれます。2番目のテストは、注文が見つからない場合、isError()がtrueを返すことを確認します。これはクライアント/modelが別の方法で処理する必要があることを知るための重要な信号です。
✓ shouldReturnOrderInfo_whenOrderExists
✓ shouldReturnError_whenOrderNotFound
9. よくあるエラー
- ログがstdoutに混在:stdio transportを使用していながらbanner/consoleログをオフにするのを忘れると、クライアントがJSON-RPCメッセージが破損した状態で受け取り、parseエラーが非常にデバッグしづらくなります。手動テスト時にファイルにリダイレクトすることで、
stdoutがMCPメッセージのみを含むことを確認してください。 - ツールの説明が不十分:説明が「lookup order」だけの場合、modelがいつこのツールを呼び出すべきかわからないことがあります。期待される入出力を明確に記述してください。
- 入力スキーマに
requiredがない:modelが必須フィールドなしでツールを呼び出す可能性があり、パラメータをキャストするときにhandlerがNullPointerExceptionになります。handlerで必ずパラメータを検証してください。スキーマを完全に信頼しないこと。 - 非同期サーバー(
McpServer.async)を使用しているが、正しくawaitしていない:多くのツールリクエストが同時に到着すると、race conditionが発生しやすくなります。reactiveに不慣れな場合は、このガイドのようにMcpServer.syncから始めてください。
MCPサーバーを個別に書くべきではない場合: システムが既に安定したREST APIを持っており、単一のクライアントから直接呼び出すだけの場合、MCPレイヤーを追加するのは余分なオーバーヘッドです。複数のAIクライアント(Claude Desktop、IDEプラグイン、自作エージェント)が同じツールを使用したい場合にのみ、MCPへの投資は価値があります。各側を個別に統合する必要がなくなります。
10. まとめ
スキーマを明確に定義した完全なMCPサーバーを構築しました:ツールを宣言し、ロジックハンドラーを書き、さらにリソースを公開し、transportと独立してhandlerをテストします。次のステップは自然にSSE transportを試して、stdioの代わりにHTTP経由でサーバーを実行すること、またはドメイン内の一般的な反復的なタスクのプロンプトテンプレートを追加することです。