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はprompt complaint_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つの質問を自分に尋ねることができます:

  1. 状態を変更するか、modelが呼び出しのタイミングを自動的に決定する必要がありますか? はい の場合、toolを使用します。
  2. 参照データで、URIで識別でき、複数回読み取ることができますか? はい の場合、resourceを使用します。
  3. ユーザーがいくつかのパラメータで繰り返し有効にしたいプロセスですか? はい の場合、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は正しくパラメータを入力する必要があります。

mcpspring-aiaillmjava