Model Context ProtocolのJava SDKでSTDIOのMCPサーバーを作る例。
|
java-sdk 0.8を使って、STDIOでMCPサーバーを実装してみる。
STDIOは、MCPクライアントとの通信を(HTTPでなく)標準入出力で行う方式。
なので、ログ出力をしたい場合は標準エラーに出力しなければならない。(System.out.println()で標準出力に出力すると、MCPクライアントが通信データとして拾ってしまい、エラーになる)
(slf4j-simpleの場合、ログメッセージを標準エラーに出してくれるらしい)
ここでは、Hogeのデータを扱うMCPサーバーを作ってみる。[2025-05-21]
Hogeとはなんじゃい、と言うと、ファイルシステムのファイルやデータベースのテーブルのようなものだと思ってくれればいい。
すなわち、Hoge名はファイル名やテーブル名、Hoge一覧はファイル一覧やテーブル一覧といった感じ。
Hogeのデータは適当なテキスト。
実装する機能は以下のもの。
これらは全て必要というわけではない。最小限で試したいならどれかひとつ実装すればいい。
Gradle 8.13のbuild.gradleの例。
plugins { id 'java' id 'application' id 'io.github.goooler.shadow' version '8.1.8' } group = 'com.example.mcp' version = '0.0.1-SNAPSHOT' java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } tasks.withType(JavaCompile) { task -> task.options.encoding = 'UTF-8' } repositories { mavenCentral() } dependencies { implementation 'io.modelcontextprotocol.sdk:mcp:0.8.1' implementation "org.slf4j:slf4j-simple:2.0.7" testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'io.modelcontextprotocol.sdk:mcp-test:0.8.1' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { useJUnitPlatform() } application { applicationName = 'hoge-mcp-server' mainClass = 'com.example.mcp.HogeMcpMain' } shadowJar { archiveBaseName = 'hoge-mcp-server' archiveClassifier = 'all' mergeServiceFiles() }
MCPのSDKはJava17でコンパイルされているようなので、JavaのバージョンにはJava17以降を指定する。[2025-04-19]
出来上がったMCPサーバーはjavaコマンドで実行できる必要がある。
依存ライブラリーのクラスパスを列挙するのは面倒なため、依存ライブラリーを全て含んだfatJarをshadowJarで作成するようにしてある。
package com.example.mcp;
import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpServerTransportProvider;
public class HogeMcpMain { private static final Logger LOG = LoggerFactory.getLogger(HogeMcpMain.class); public static void main(String[] args) { LOG.info("HogeMcpMain start"); McpServerTransportProvider transportProvider = new StdioServerTransportProvider(new ObjectMapper()); McpSyncServer syncServer = HogeMcpServer.syncServer(transportProvider); LOG.info("serverInfo={}", syncServer.getServerInfo()); } }
java-sdk 0.8のSTDIOでは、StdioServerTransportProviderを使ってMCPサーバーを構築する。
MCPサーバーは、別スレッドでMCPクライアントからの要求待ちを開始するようだ。
MCPサーバーを起動したらmainメソッドは終了してしまう。
(受信スレッドが生きているので、アプリケーション自体は終了しない)
MCPクライアントへ返す情報を管理するサーバーを構築する部分。
(McpServerTransportProviderを使うのは、java-sdk 0.8.0で変更された仕様らしい)
package com.example.mcp;
import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerTransportProvider;
public class HogeMcpServer { public static McpSyncServer syncServer(McpServerTransportProvider transportProvider) { var capabilities = McpSchema.ServerCapabilities.builder() .resources(false, false) .tools(false) .prompts(false) // .logging() .build(); var syncServer = McpServer.sync(transportProvider) .serverInfo("hoge-mcp-server", "0.0.1") .capabilities(capabilities) .resources(HogeMcpResource.syncResourceSpecification()) .tools(HogeMcpTool.syncToolSpecification()) .prompts(HogeMcpPrompt.syncPromptSpecification()) .build(); return syncServer; } }
capabilitiesで、自作MCPサーバーがリソース・ツール・プロンプトをクライアントに返せるかどうかを指定する。
引数のlistChangedは、基本的にfalseでいいと思う。[2025-05-21]
MCPサーバーには、リソースやツール・プロンプトに追加削除があった場合、MCPクライアントに通知する機能があるようだ。listChangedはその通知を行うかどうかのフラグだと思う。
リソースはともかく、ツールやプロンプトは、普通は増減しないだろうから。
capabilitiesでツールを返すように指定したら、McpServerのtools()でツールを定義する。
同様に、capabilitiesでリソースを返すように指定したら、McpServerのresources()でリソースを定義する。
MCPリソースはデータそのもの。[2025-05-13]
Claude Desktopの場合、リソースを読み込ませて、そのリソースに対する指示を行うことが出来る。
package com.example.mcp;
import java.net.URI; import java.util.ArrayList; import java.util.List; import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
public class HogeMcpResource { private static List<String> HOGE_NAMES = List.of("abc", "def", "ghi"); public static List<SyncResourceSpecification> syncResourceSpecification() { var list = new ArrayList<SyncResourceSpecification>(); for (String hogeName : HOGE_NAMES) { list.add(createResource(hogeName)); } return list; } private static SyncResourceSpecification createResource(String hogeName) { var resource = new McpSchema.Resource( // "hoge://%s".formatted(hogeName), // uri hogeName, // name "%s data".formatted(hogeName), // description "text/plain", // mimeType null // annotations ); return new SyncResourceSpecification(resource, HogeMcpResource::getHogeData); } static ReadResourceResult getHogeData(McpSyncServerExchange exchange, ReadResourceRequest request) { var uri = URI.create(request.uri()); String hogeName = uri.getAuthority(); String result = switch (hogeName) { case "abc" -> """ aaa bbb ccc """; case "def" -> """ dddd eee ff """; case "ghi" -> """ g1 g2 h1 h2 i0 """; default -> throw new RuntimeException("not found resource. hogeName=" + hogeName); }; var content = new TextResourceContents(request.uri(), "text/plain", result); return new ReadResourceResult(List.of(content)); } }
McpSchema.Resourceでリソースを定義する。
リソースを扱うURIを決め、リソース名やdescriptionを定義する。
URIのスキーマ名は独自に定義して良い。
リソース本体のデータを返す関数はReadResourceRequestを受け取るが、この中に対象リソースのURIが入ってくる。
上記の例だと、「hoge://abc
」「hoge://def
」「hoge://ghi
」という3つのリソース(URI)を作ってMCPクライアントに返している。
MCPクライアントはこのURIを指定してリソースのデータを取得しようとする(用意しておいたメソッドが呼ばれる)ので、URIに応じたデータを返す。
MCPツールは、LLM(いわゆるAI)から呼ばれて処理を行う関数。
package com.example.mcp;
import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
public class HogeMcpTool { private static final Logger LOG = LoggerFactory.getLogger(HogeMcpTool.class); private static final ObjectMapper objectMapper = new ObjectMapper();
public static List<SyncToolSpecification> syncToolSpecification() { return List.of(listHogeTool(), getHogeTool()); }
// Hoge一覧を返すツール(引数なし) private static SyncToolSpecification listHogeTool() { var tool = new McpSchema.Tool( "list-hoge", // tool name "list hoge names", // description new McpSchema.JsonSchema( "object", // type Map.of(), // properties List.of(), // required property null // additionalProperties )); return new SyncToolSpecification(tool, HogeMcpTool::listHoge); } private static CallToolResult listHoge(McpSyncServerExchange exchange, Map<String, Object> arguments) { try { var result = listHogeMain(); String text = objectMapper.writeValueAsString(result); var content = new McpSchema.TextContent(text); return new CallToolResult(List.of(content), false); } catch (RuntimeException e) { LOG.warn("listHoge error", e); throw e; } catch (Exception e) { LOG.warn("listHoge error", e); throw new RuntimeException(e); } } record ListHogeResult(String hogeName) { } private static List<String> HOGE_NAMES = List.of("abc", "def", "ghi"); static List<ListHogeResult> listHogeMain() { return HOGE_NAMES.stream().map(ListHogeResult::new).toList(); }
// Hogeデータを取得するツール(引数はHoge名) static final String HOGE_NAME = "hogeName"; private static SyncToolSpecification getHogeTool() { var propertyList = List.of( new ToolProperty(HOGE_NAME, new ToolPropertyBody("string", "hoge name"), true) ); Map<String, Object> propertyMap = propertyList.stream() .collect(Collectors.toMap(ToolProperty::name, p -> (Object) p)); var tool = new McpSchema.Tool( "get-hoge", // tool name "get hoge data", // description new McpSchema.JsonSchema( "object", // type propertyMap, // properties List.of(HOGE_NAME), // required property null // additionalProperties )); return new SyncToolSpecification(tool, HogeMcpTool::getHoge); } private static CallToolResult getHoge(McpSyncServerExchange exchange, Map<String, Object> arguments) { try { String result = getHogeMain(arguments); var content = new McpSchema.TextContent(result); return new CallToolResult(List.of(content), false); } catch (RuntimeException e) { LOG.warn("getHoge error", e); throw e; } catch (Exception e) { LOG.warn("getHoge error", e); throw new RuntimeException(e); } } static String getHogeMain(Map<String, Object> arguments) { String hogeName = (String) arguments.get(HOGE_NAME); return switch (hogeName) { case "abc" -> """ aaa bbb ccc """; case "def" -> """ dddd eee ff """; case "ghi" -> """ g1 g2 h1 h2 i0 """; default -> throw new RuntimeException("not found hoge. hogeName=" + hogeName); }; }
record ToolProperty(String name, ToolPropertyBody body, boolean required) { } record ToolPropertyBody(String type, String description) { } }
McpSchema.Toolでツール(MCPクライアントから呼ばれる関数)を定義する。
ツールは複数定義できるが、ツール名(関数名)は自作ツール内で被らない名前にする。
ツール名はcamelCaseでもハイフン「-」区切りでも良い。
Claude Desktopがユーザーに「MCPサーバーのツール(関数)の呼び出し許可」を求める際にこのツール名が表示されることがあるので、分かりやすい名前にしておくのが良さそう。
なにげにdescription(説明)は重要。
普通のアプリケーションならdescriptionは人間が読むものなので、ある程度適当でも良かった。なんならdescriptionが無くてもアプリケーションは動作した。
しかしLLM(いわゆるAI)では、descriptionの内容もLLMが読む。なので、むしろLLMに分かりやすい内容をdescriptionに記述しておくのが良さそう。
(対象のLLMが日本語を解するなら、descriptionは日本語で書いても良い)
McpSchema.JsonSchemaはツール(関数)の引数を定義する。
SyncToolSpecificationのコンストラクターの第2引数で、この関数の処理本体を指定する。
処理本体のメソッドのCallToolResultでTextContentを渡せば、テキスト(文字列)をクライアントに返すことが出来る。
上記のlistHoge()では、Hoge名の一覧を固定でHOGE_NAMESに定義しておき、それを返している。
例えばDBにアクセスするMCPサーバーなら、テーブル一覧を返すような関数になるだろう。
ちなみに、処理本体(xxxMain())がprivateでないのは、そのメソッドだけ呼び出してテストできるようにする為。
MCPプロンプトは、ユーザーがプロンプトを入力する代わりに使える定型的なプロンプトを返すもの。[2025-05-21]
package com.example.mcp;
import java.util.List; import java.util.Map; import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.PromptArgument; import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
public class HogeMcpPrompt { public static List<SyncPromptSpecification> syncPromptSpecification() { return List.of(assistantPrompt(), listPrompt(), showPrompt()); }
// LLMに対するプロンプト private static SyncPromptSpecification assistantPrompt() { var prompt = new McpSchema.Prompt( "assistant-prompt", // name "LLMに対するプロンプト", // description List.of() // arguments ); return new SyncPromptSpecification(prompt, HogeMcpPrompt::assistant); } static GetPromptResult assistant(McpSyncServerExchange exchange, GetPromptRequest request) { String text = """ あなたは執事のように振る舞ってください。 ユーザーへの回答文の冒頭で、なるべく「お嬢様」と呼びかけるようにしてください。 """; var content = new McpSchema.TextContent(text); var message = new PromptMessage(McpSchema.Role.ASSISTANT, content); return new GetPromptResult( "", // description List.of(message) // messages ); }
// Hoge一覧を表示させるプロンプト(引数なし) private static SyncPromptSpecification listPrompt() { var prompt = new McpSchema.Prompt( "list-hoge-prompt", // name "hoge名一覧を表示するプロンプト", // description List.of() // arguments ); return new SyncPromptSpecification(prompt, HogeMcpPrompt::listHoge); } static GetPromptResult listHoge(McpSyncServerExchange exchange, GetPromptRequest request) { String text = """ hoge一覧を表示してください。 """; var content = new McpSchema.TextContent(text); var message = new PromptMessage(McpSchema.Role.USER, content); return new GetPromptResult( "hoge名の一覧を取得・表示する", // description List.of(message) // messages ); }
// Hogeデータを表示させるプロンプト(引数はHoge名) static final String HOGE_NAME = "hogeName"; private static SyncPromptSpecification showPrompt() { var prompt = new McpSchema.Prompt( "show-hoge-prompt", // name "hogeの内容を表示するプロンプト", // description List.of( // arguments new PromptArgument( HOGE_NAME, // name "hoge名", // description true) // required )); return new SyncPromptSpecification(prompt, HogeMcpPrompt::showHoge); } static GetPromptResult showHoge(McpSyncServerExchange exchange, GetPromptRequest request) { Map<String, Object> arguments = request.arguments(); var hogeName = arguments.get(HOGE_NAME); String text = """ hoge名 %s のデータを表示してください。 """.formatted(hogeName); var content = new McpSchema.TextContent(text); var message = new PromptMessage(McpSchema.Role.USER, content); return new GetPromptResult( "%s のデータを取得・表示する".formatted(hogeName), // description List.of(message) // messages ); } }
McpSchema.Promptでプロンプトを定義する。
MCPプロンプトはいわばテンプレートのようなものだが、Hoge名などの具体的な値を埋めて、すぐに使えるプロンプトという形で返す。
PromptMessageのコンストラクターの第1引数でロール(役割)を決める。
USERはユーザーが入力するプロンプトの代わりとなるもの。
ASSISTANTはLLM(いわゆるAI)に対する指示となるもの。
ビルド(fatJarの生成)は以下のコマンドで行う。
$ ./gradlew shadowJar $ ls build/libs/ hoge-mcp-server-0.0.1-SNAPSHOT-all.jar
実行方法は以下のようなイメージ。
$ java -jar hoge-mcp-server-0.0.1-SNAPSHOT-all.jar
このようなコマンドをClaude Desktopのclaude_desktop_config.jsonに(フルパスで)記述することになる。
Claude Desktopのclaude_desktop_config.jsonに自作MCPサーバーの定義を追加する。
{ "mcpServers": { "hoge-mcp-server": { "command": "C:/Program Files/Java/jdk-21/bin/java", "args": ["-jar", "C:/mcp-example/build/libs/hoge-mcp-server-0.0.1-SNAPSHOT-all.jar"] } } }
この状態でClaude Desktopを起動すると、hoge-mcp-serverが実行される(はず)。
もしClaude Desktopを再起動するなら、メニューの「ファイル」→「終了」でバックグラウンドプロセスごと終了させてから起動する必要がある。
なお、System.out.println()で標準出力にメッセージを出力すると、Claude Desktop上にエラーメッセージが出る。[/2024-04-13]
MCP hoge-mcp-server: Unexpected token 'M', "McpExample"... is not valid JSON MCP hoge-mcp-server: Unexpected token 'E', "HogeMcp"... is not valid JSON MCP hoge-mcp-server: Unexpected token 'I', "Implementa"... is not valid JSON MCP hoge-mcp-server: Unexpected token 'M', "McpExample"... is not valid JSON
エラーメッセージの内容が途中で途切れているので分かりづらいが、試しに「McpExample
start」みたいなメッセージをSystem.out.println()で出していたので、それがClaude Desktopに拾われてこのようなエラーになったようだ。
(STDIOでは、MCPクライアントとMCPサーバー間の通信データは標準入出力で送受信する為)
(issueがあったのでClaude Desktopのバグかと思ったんだけど、そうではなかった模様)
なお、このようなエラーは出ても、MCPサーバー自体は一応動作するようだ。
自作リソースを使う例。[2025-05-13]
自作ツールを実行する例。
「Hoge一覧を返すツール」を実行してみる。
続けて「abcのデータを見せて」(abcはHoge名)のように入力すれば、HogeMcpTool.getHoge()が実行され、返されたデータが表示される。
自作プロンプトを読み込む例。[2025-05-21]
アシスタントのプロンプト(LLM向けの指示)を適用させてみる。
今回の例だと、以下のような回答が表示された(笑)
「こんにちは、お嬢様。執事として喜んでお手伝いさせていただきます。何かご質問やご要望がございましたら、どうぞお気軽にお申し付けください。今日は何をお手伝いできますでしょうか?」