MCPのtool、resource、promptの違いとは
1. 概要
MCP serverを書くとき、クライアントに機能を提供する3つの方法があります:tool、resource、prompt。この3つはどちらもサーバー側で宣言され、クライアントが読み込めるリストに表示されます。したがって、多くの人が無差別に選んだり、すべてをtoolに変えてしまいます。
この記事は、3つのprimitiveを明確な基準で区別します:誰がそれをいつ使うかを決めるのか。Spring AIを使用して、同じビジネスドメイン(注文)に対して3つすべてを実装します。各タイプのテストも作成します。記事の最後に、各状況に合わせて適切なprimitiveを選ぶためのテーブルがあります。
2. 3つのprimitiveと操作者
MCP仕様は、各primitiveを「操作者」に関連付けます:
| Primitive | 操作者 | 本質 | JSON-RPCメソッド |
|---|---|---|---|
| Tool | Model(LLMが自動的に呼び出しを決定) | アクション、副作用の可能性あり | tools/list, tools/call |
| Resource | Application(host/clientがコンテキストを選択) | 読み取り専用データ、URIで識別 | resources/list, resources/read, resources/templates/list |
| Prompt | User(ユーザーが能動的に選択) | パラメータ付きの会話テンプレート | prompts/list, prompts/get |
要するに:
- ToolはLLMが呼び出すことが許可された関数のようなもので、例えば注文をキャンセルするかメールを送信します。
- Resourceはアプリケーションがコンテキストに添付するファイルのようなもので、例えば注文
A100の詳細です。 - Promptはユーザーが実行するスラッシュコマンドのようなもので、例えば
/complaint_replyです。
この記事の残りの部分では、コードを使って各タイプを明確にします。
3. プロジェクトの準備
Spring Boot 3.xおよびSpring AI 1.1以降が必要です。バージョン1.1から、Spring AIはアノテーション@McpTool、@McpResource、および@McpPromptをサポートしています。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
次に、serverの最小限の設定です:
spring.ai.mcp.server.name=order-mcp-server
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.type=SYNC
spring.ai.mcp.server.protocol=STREAMABLE
デフォルトでは、Spring AIはMCPアノテーション付きのbeanを自動的にスキャンし、serverに登録します。追加の宣言は必要ありません。
記事全体で共有されるビジネスドメインは、recordとin-memoryリポジトリで構成されています:
public enum OrderStatus { NEW, SHIPPED, CANCELLED }
public record Order(String id, String customerName, BigDecimal total, OrderStatus status) {
public Order withStatus(OrderStatus newStatus) {
return new Order(id, customerName, total, newStatus);
}
}
@Repository
public class OrderRepository {
private final Map<String, Order> orders = new ConcurrentHashMap<>(Map.of(
"A100", new Order("A100", "Lan", new BigDecimal("250000"), OrderStatus.NEW),
"A101", new Order("A101", "Minh", new BigDecimal("990000"), OrderStatus.SHIPPED)));
public Optional<Order> findById(String id) {
return Optional.ofNullable(orders.get(id));
}
public Order save(Order order) {
orders.put(order.id(), order);
return order;
}
}
4. Tool:modelが決定するアクション
Toolは、推論中にLLMが自ら呼び出すことを選択するものです。クライアントは、descriptionを含むtoolのリストをmodelに送信します。Modelはdescriptionを読み取り、呼び出すかどうか、どのパラメータで呼び出すかを決定します。したがって、toolはアクション、特に状態を変更するアクションに適しています。
@Component
public class OrderTools {
private final OrderRepository repository;
public OrderTools(OrderRepository repository) {
this.repository = repository;
}
@McpTool(name = "cancel_order",
description = "未出荷の注文をキャンセルします。キャンセル結果をテキスト形式で返します。")
public String cancelOrder(
@McpToolParam(description = "注文ID、例:A100", required = true) String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("注文 " + orderId + " が見つかりません"));
if (order.status() == OrderStatus.SHIPPED) {
return "注文" + orderId + "は既に配送済みのため、キャンセルできません。";
}
repository.save(order.withStatus(OrderStatus.CANCELLED));
return "注文" + orderId + "をキャンセルしました";
}
}
上記のコードで注意すべき点がいくつかあります:
@McpToolはmethodをtoolとして登録し、名前はcancel_orderです。Modelはdescriptionに基づいて、このtoolをいつ呼び出すかを決定するため、詳細なdescriptionを記述する必要があります。@McpToolParamはパラメータのJSON Schemaを生成します。ModelはorderIdが必須の文字列であることを認識し、値の例を確認できます。- スローされた例外は、クライアントに誤り結果(
isError: true)として返されます。Modelはエラーを読み取り、パラメータを自動修正して再度呼び出すことができます。
Modelがtoolの呼び出しを決定すると、クライアントは次のリクエストを送信します:
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"cancel_order","arguments":{"orderId":"A100"}}}
Serverは次を返します:
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"注文A100をキャンセルしました"}],"isError":false}}
注意: Toolは副作用を伴うため、host側でユーザーの確認ステップが必要です。ほとんどのクライアントはこのメカニズムを備えています。ただし、server側はまだ権限と状態を自動的にチェックする必要があります。modelが常に正しく呼び出すと想定しないでください。
5. Resource:アプリケーションが選択するデータ
Resourceは読み取り専用データであり、各resourceはURIで識別されます。Modelはtoolのようにresourceを呼び出しません。代わりに、hostアプリケーションがどのresourceをコンテキストに含めるかを決定します。Hostはユーザーにリストから選択させたり、独自のルールに従って自動的に添付したり、更新を受け取るためにsubscribeさせることができます。
@Component
public class OrderResources {
private final OrderRepository repository;
public OrderResources(OrderRepository repository) {
this.repository = repository;
}
@McpResource(uri = "order://{orderId}",
name = "order-detail",
description = "JSON形式の注文詳細",
mimeType = "application/json")
public String orderDetail(String orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("注文 " + orderId + " が見つかりません"));
return """
{"id":"%s","customer":"%s","total":%s,"status":"%s"}"""
.formatted(order.id(), order.customerName(), order.total(), order.status());
}
}
上記のコードの動作方法:
uri = "order://{orderId}"はresourceテンプレートです。クライアントはresources/templates/listを通じてこのテンプレートを確認します。- URI内の変数
{orderId}はmethodの同じ名前のパラメータにバインドされます。Spring Bootはコンパイル時に-parametersフラグを有効にするため、名前によるバインディングは追加の設定なしで機能します。 mimeTypeはクライアントに返されるコンテンツがJSONであることを知らせ、クライアントが適切に表示または処理できるようにします。
これはresourceを読み込むリクエストと返された結果です:
--> {"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"order://A101"}}
<-- {"jsonrpc":"2.0","id":3,"result":{"contents":[{"uri":"order://A101",
"mimeType":"application/json",
"text":"{\"id\":\"A101\",\"customer\":\"Minh\",\"total\":990000,\"status\":\"SHIPPED\"}"}]}}
疑問に思うかもしれません:なぜ単にget_order toolを使わないのですか?Tool get_orderはまだ有効です。しかし、データがアプリケーションが事前にロードしたいもの、またはユーザーが会話に「ピン止め」したいものである場合、resourceが3つの理由でより合理的です:
- Resourceは呼び出すかどうかを決定するだけのためにmodelの推論ラウンドを消費しません。
- ResourceはURI安定しているため、hostはキャッシュしてUIに表示できます。
- ResourceはデータがASH変更されたときに通知を受け取るために
subscribeをサポートしています。
注意: すべてのクライアントがresourceを完全にサポートしているわけではありません。一部のクライアントはtoolのみを使用します。そのデータがすべてのクライアントでmodelに到達することが必須である場合は、対応する読み取り toolを追加で提供することを検討してください。
6. Prompt:ユーザーが選択するテンプレート
Promptはパラメータ付きの会話テンプレートであり、ユーザーが能動的に有効にします。UIを持つクライアントでは、promptは通常スラッシュコマンドまたはメニュー項目として表示されます。Serverは事前入力されたmessageのリストを返します。クライアントはそのリストを会話に挿入してからmodelに送信します。
@Component
public class OrderPrompts {
@McpPrompt(name = "complaint_reply",
description = "注文に関する顧客の苦情に対応するメール返信を作成")
public GetPromptResult complaintReply(
@McpArg(name = "orderId", description = "苦情の対象となった注文コード", required = true) String orderId,
@McpArg(name = "tone", description = "トーン:丁寧 | 親しみやすい") String tone) {
String style = (tone == null || tone.isBlank()) ? "丁寧" : tone;
String text = """
resource order://%sを読み、その後、顧客の苦情に対する返信メールを作成してください。
トーン:%s。注文がまだ配送されていない場合は、tool cancel_orderでのキャンセルを提案してください。
""".formatted(orderId, style);
return new GetPromptResult("注文" + orderId + "の苦情返信",
List.of(new PromptMessage(Role.USER, new TextContent(text))));
}
}
コード説明:
@McpPromptはpromptcomplaint_replyを登録します。@McpArgはクライアントがprompts/getを呼び出す前にユーザーに尋ねるパラメータを宣言します。- クラス
GetPromptResult、PromptMessage、Role、およびTextContentはio.modelcontextprotocol.spec.McpSchemaにあります。 - Promptの内容はresourceとtoolの両方を参照します。これは3つのprimitiveがどのように連携するかの方法です:ユーザーはpromptを選択し、アプリケーションはresourceをロードし、modelがtoolを呼び出すかどうかを決定します。
prompts/getを呼び出すときの返された結果:
--> {"method":"prompts/get","params":{"name":"complaint_reply","arguments":{"orderId":"A100"}}}
<-- {"result":{"description":"注文A100の苦情返信",
"messages":[{"role":"user","content":{"type":"text",
"text":"resource order://A100を読み、その後、顧客の苦情に対する返信メールを作成してください。\nトーン:丁寧。..."}}]}}
注意: Modelはpromptを自分で呼び出しません。LLMがプロセスを自動的にトリガーさせたい場合は、promptではなくtoolが必要です。
7. 主要な動作のテスト
MCPアノテーション付きのmethodは純粋なJavaなので、MCPトランスポートを起動しなくてもユニットテストを直接実行できます。
class OrderMcpFeaturesTest {
private final OrderRepository repository = new OrderRepository();
private final OrderTools tools = new OrderTools(repository);
private final OrderResources resources = new OrderResources(repository);
private final OrderPrompts prompts = new OrderPrompts();
@Test
void cancelOrder_changesStatus_whenOrderIsNew() {
String result = tools.cancelOrder("A100");
assertThat(result).isEqualTo("注文A100をキャンセルしました");
assertThat(repository.findById("A100")).get()
.extracting(Order::status).isEqualTo(OrderStatus.CANCELLED);
}
@Test
void cancelOrder_refuses_whenOrderIsShipped() {
assertThat(tools.cancelOrder("A101")).contains("キャンセルできません");
assertThat(repository.findById("A101")).get()
.extracting(Order::status).isEqualTo(OrderStatus.SHIPPED);
}
@Test
void orderDetail_isReadOnly() {
String json = resources.orderDetail("A100");
assertThat(json).contains("\"status\":\"NEW\"");
assertThat(repository.findById("A100")).get()
.extracting(Order::status).isEqualTo(OrderStatus.NEW);
}
@Test
void complaintReply_usesDefaultTone() {
GetPromptResult result = prompts.complaintReply("A100", null);
assertThat(result.messages()).hasSize(1);
assertThat(((TextContent) result.messages().get(0).content()).text())
.contains("order://A100")
.contains("トーン:丁寧");
}
}
これらの4つのテストは、各primitiveの「契約」を正しく検証します:
- Toolは有効な場合は状態を変更し、無効な場合は拒否します。
- Resourceは副作用がありません:読み取り後、注文の状態は変わったままです。
- Promptはパラメータを正しく入力し、ユーザーが
toneを渡さない場合はデフォルト値を適用します。
エンドツーエンドテストが必要な場合は、MCP Java SDKのMcpClientを使用して、実際のtransport経由でtools/listとresources/readを呼び出すことができます。
8. どのprimitiveを選ぶか?
次の3つの質問を自分に尋ねることができます:
- 状態を変更するか、modelが呼び出しのタイミングを自動的に決定する必要がありますか? はい の場合、toolを使用します。
- 参照データで、URIで識別でき、複数回読み取ることができますか? はい の場合、resourceを使用します。
- ユーザーがいくつかのパラメータで繰り返し有効にしたいプロセスですか? はい の場合、promptを使用します。
一般的な間違い:
- すべてをtoolに変える。 toolのリストが長いほど、modelが間違った選択を行いやすくなり、説明部分により多くのtokenが消費されます。
- Resourceに副作用がある、例えば
order://A100を読むと注文が「既読」とマークされるなど。クライアントはプレビューを表示するためにも含め、いつでもresourceを読み取ることができるため、resourceは複数回の読み取りに対して安全である必要があります。 - Resourceが大きすぎるデータを返す。 コンテンツ全体がmodelのcontext windowに入ります。URIテンプレートで分割するか、ページネーションを使用してください。
- 説明が曖昧、例えば単に「注文処理」と記載。toolの場合、descriptionはmodelが呼び出すかどうかを決定するための唯一の根拠です。
- ビジネスロジックをpromptに詰め込む。 Promptはmodelに送信されるテキストに過ぎません。注文配送後のキャンセル禁止など、必須のルールはtoolのコードに実装する必要があります。
9. 結論
Tool、resource、promptの主な違いは操作者です。Modelがtoolを呼び出します。アプリケーションがresourceを選択します。ユーザーがpromptを有効化します。正しいprimitiveを選択すると、serverコードがシンプルになり、modelの誤呼び出しが減り、クライアント内の体験もより自然になります。
Spring AI 1.1では、3つすべてがbeanのmethodに1つのannotationを追加するだけで済みます:@McpTool、@McpResource、@McpPrompt。これらはまだ純粋なJavaのmethodなので、コア動作を簡単にユニットテストできます:toolは状態チェックを実行し、resourceは読み取り専用で、promptは正しくパラメータを入力する必要があります。