MCP tool, resource, prompt의 차이점은 무엇인가

1. 개요

MCP 서버를 작성할 때, 당신의 기능을 클라이언트에 제공하는 세 가지 방법이 있습니다: tool, resource, prompt. 셋 다 서버 쪽에서 선언되며, 클라이언트가 읽을 수 있는 목록에 모두 나타납니다. 그래서 많은 사람들이 무분별하게 선택하거나 모든 것을 tool로 만들고 있습니다.

이 글은 이 세 가지 primitive를 명확한 기준으로 구분합니다: 언제 사용할지 누가 결정하는가. 동일한 비즈니스 도메인인 주문을 위해 Spring AI로 세 가지를 모두 구현합니다. 각 종류에 대한 테스트도 작성합니다. 글의 마지막에는 각 상황에 맞는 primitive를 선택하도록 도와주는 표가 있습니다.

2. 세 가지 primitive와 제어자

MCP 명세는 각 primitive를 ’제어 주체’와 연결합니다:

Primitive 누가 제어하는가 성질 JSON-RPC 메서드
Tool Model (LLM이 자체적으로 호출 결정) 동작, side effect가 있을 수 있음 tools/list, tools/call
Resource Application (호스트/클라이언트가 컨텍스트 선택) 읽기 전용 데이터, 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>

다음으로 서버의 최소 설정:

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을 자동으로 스캔하고 서버에 등록합니다. 추가 설정이 필요하지 않습니다.

이 글 전체에서 사용할 비즈니스 도메인은 하나의 record와 메모리 내 repository로 구성됩니다:

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: 모델이 결정하는 동작

Tool은 LLM이 추론 중에 스스로 호출하도록 선택하는 것입니다. 클라이언트는 tool 목록과 설명을 모델에 보냅니다. 모델은 설명을 읽고 호출 여부와 어떤 파라미터로 호출할지를 결정합니다. 따라서 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 = "주문 코드, 예: 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은 메서드를 cancel_order라는 이름의 tool로 등록합니다. 모델은 description을 기반으로 이 tool을 언제 호출할지 결정하므로 구체적인 설명을 작성해야 합니다.
  • @McpToolParam은 파라미터에 대한 JSON Schema를 생성합니다. 모델은 orderId가 필수 문자열임을 알고 예제 값을 볼 수 있습니다.
  • 던져진 Exception은 클라이언트에 오류 결과(isError: true)로 반환됩니다. 모델은 이 오류를 읽고 파라미터를 수정한 후 다시 호출할 수 있습니다.

모델이 tool을 호출하기로 결정했을 때 클라이언트는 다음 요청을 보냅니다:

{"jsonrpc":"2.0","id":7,"method":"tools/call",
 "params":{"name":"cancel_order","arguments":{"orderId":"A100"}}}

서버는 다음을 반환합니다:

{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"주문 A100을(를) 취소했습니다"}],"isError":false}}

참고: Tool은 side effect가 있으므로 호스트에서 사용자의 확인 단계가 수반되어야 합니다. 대부분의 클라이언트가 이 메커니즘을 가지고 있습니다. 그럼에도 서버는 권한과 상태를 자체적으로 검증해야 합니다. 모델이 항상 올바르게 호출한다고 가정하지 마십시오.

5. Resource: 애플리케이션이 선택하는 데이터

Resource는 읽기 전용 데이터이며, 각 resource는 URI로 식별됩니다. 모델은 tool을 호출하는 것처럼 resource를 호출하지 않습니다. 대신 호스트 애플리케이션이 어떤 resource를 컨텍스트에 포함시킬지 결정합니다. 호스트는 사용자가 목록에서 선택하게 하거나, 자신의 규칙에 따라 자동으로 첨부하거나, 구독하여 업데이트를 받을 수 있습니다.

@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 template입니다. 클라이언트는 resources/templates/list를 통해 이 템플릿을 볼 수 있습니다.
  • URI의 {orderId} 변수는 메서드의 동일한 이름의 파라미터에 바인딩됩니다. 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을 사용하지 않을까요? get_order tool은 여전히 유효합니다. 하지만 데이터가 애플리케이션이 미리 로드하고자 하는 것이거나, 사용자가 대화에 “고정”하고 싶다면 resource가 더 나은 이유는 세 가지입니다:

  • Resource는 호출할지 말지 결정하기 위해 모델의 추론 단계를 소비하지 않습니다.
  • Resource는 안정적인 URI를 가지므로 호스트는 캐시하고 UI에 표시할 수 있습니다.
  • Resource는 데이터가 변경될 때 알림을 받기 위해 subscribe를 지원합니다.

참고: 모든 클라이언트가 resource를 완전히 지원하지는 않습니다. 일부 클라이언트는 tool만 사용합니다. 해당 데이터가 모든 클라이언트에서 모델에 도달해야 하는 필수 데이터라면, 해당 읽기 tool을 추가로 제공하는 것을 고려하십시오.

6. Prompt: 사용자가 선택하는 템플릿

Prompt는 파라미터가 있는 대화 템플릿이며, 사용자가 직접 활성화합니다. UI가 있는 클라이언트에서는 prompt가 보통 슬래시 명령어나 메뉴 항목으로 나타납니다. 서버는 미리 작성된 메시지 목록을 반환합니다. 클라이언트는 해당 목록을 대화에 삽입한 후 모델에 보냅니다.

@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 모두를 언급합니다. 이것이 세 가지 primitive가 함께 작동하는 방식입니다: 사용자가 prompt를 선택하고, 애플리케이션이 resource를 로드하며, 모델이 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을 읽은 다음 고객 불만에 대한 회신 편지를 작성하세요. 말투: 정중함. ..."}}]}}

참고: 모델은 prompt를 직접 호출하지 않습니다. LLM이 프로세스를 자동으로 활성화하도록 하려면 prompt가 아니라 tool이 필요합니다.

7. 핵심 동작 테스트

MCP 어노테이션이 있는 메서드는 여전히 순수 Java이므로, MCP transport를 시작할 필요 없이 직접 단위 테스트할 수 있습니다.

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("말투: 정중함");
    }
}

이 네 가지 테스트는 각 primitive의 “계약”을 검증합니다:

  • Tool은 유효할 때 상태를 변경하고, 유효하지 않을 때는 거부합니다.
  • Resource는 side effect가 없습니다: 읽은 후에도 주문 상태는 유지됩니다.
  • Prompt는 파라미터를 올바르게 채우고, 사용자가 tone을 전달하지 않을 때 기본값을 적용합니다.

엔드-투-엔드 테스트를 원한다면 MCP Java SDK의 McpClient를 사용하여 실제 transport를 통해 tools/list와 resources/read를 호출할 수 있습니다.

8. 어떤 primitive를 선택할까?

다음의 세 가지 질문을 스스로에게 물어보십시오:

  1. 상태를 변경하거나 모델이 호출할 시기를 직접 결정해야 합니까? 예라면 tool을 사용하십시오.
  2. 참조 데이터이며 URI로 식별되고 여러 번 읽을 수 있습니까? 예라면 resource를 사용하십시오.
  3. 사용자가 몇 가지 파라미터로 반복해서 실행하고 싶은 프로세스입니까? 예라면 prompt를 사용하십시오.

흔한 실수:

  • 모든 것을 tool로 만들기. tool 목록이 길수록 모델이 잘못 선택할 가능성이 높고 설명 부분에 더 많은 토큰이 소비됩니다.
  • Resource에 side effect가 있음. 예를 들어 order://A100을 읽으면 주문을 “조회됨”으로 표시합니다. 클라이언트는 언제든 resource를 읽을 수 있으며, 미리보기를 표시하기 위해서도 읽을 수 있으므로 resource는 반복 읽기에서 안전해야 합니다.
  • Resource가 너무 큰 데이터를 반환합니다. 전체 내용이 모델의 context window에 들어갑니다. URI 템플릿으로 분할하거나 페이지 매김을 사용하십시오.
  • 모호한 설명. 예를 들어 “주문 처리”만 작성합니다. tool의 경우 설명이 모델이 호출 여부를 결정하는 유일한 기준입니다.
  • Prompt에 비즈니스 로직을 넣기. Prompt는 모델에 보내는 텍스트일 뿐입니다. 배송된 주문을 취소할 수 없다는 것과 같은 필수 규칙은 tool의 코드에 있어야 합니다.

9. 결론

Tool, resource, prompt의 주요 차이점은 제어자입니다. 모델은 tool을 호출하고, 애플리케이션은 resource를 선택하며, 사용자는 prompt를 활성화합니다. 올바른 primitive를 선택하면 서버가 더 간결해지고, 모델의 잘못된 호출이 줄어들며, 클라이언트의 경험도 더 자연스러워집니다.

Spring AI 1.1에서는 bean의 메서드에 하나의 어노테이션만 필요합니다: @McpTool, @McpResource, @McpPrompt. 순수 Java 메서드이므로 핵심 동작을 쉽게 단위 테스트할 수 있습니다: tool은 상태를 검증해야 하고, resource는 읽기 전용이어야 하며, prompt는 파라미터를 올바르게 채워야 합니다.

mcpspring-aiaillmjava