mcpj 사용 설명서

기존 Java 애플리케이션의 기능을 AI 모델이 호출할 수 있도록 노출하는 라이브러리입니다. 런타임 의존성이 없으며, 단일 JAR로 Java 8부터 26까지 동작합니다. 공식 MCP Java SDK는 Java 17 이상을 요구하므로 Java 8·11 애플리케이션에는 적용할 수 없습니다.

MCP 2026-07-28 Java 8 – 26 런타임 의존성 0 Apache 2.0

이 라이브러리가 하는 일

AI 모델은 자체적으로 내부 시스템에 접근할 수 없습니다. 모델이 재고를 조회하려면 조회 수단을 정의해 제공해야 합니다. MCP는 그 수단을 정의하는 프로토콜이며, mcpj는 이 프로토콜을 구현한 서버를 Java로 작성하기 위한 라이브러리입니다.

OrderService.findOrder(orderId)와 같은 기존 메서드가 있다면 애노테이션 하나를 선언하는 것만으로 모델이 해당 메서드를 호출할 수 있습니다. 코드를 이전하거나 별도로 구현할 필요가 없습니다.

세 가지를 내보냅니다

MCP는 서버가 노출하는 대상을 세 종류로 구분하며, 각각 용도가 다릅니다.

종류누가 쓰나무엇인가
도구 모델이 실행 작업을 수행하는 단위. 모델이 필요하다고 판단하면 직접 호출합니다 재고 조회, 메일 발송
리소스 모델이 참조 참조 대상이 되는 데이터. 실행이 아니라 조회의 대상입니다 반품 정책 문서, 로그 파일
프롬프트 사용자가 선택 사전 정의된 지시문. 모델이 아니라 사용자가 목록에서 선택해 사용합니다 “이 코드를 우리 팀 규칙으로 검토해 줘”
선택 기준

모델이 스스로 판단해 호출해야 하는 기능이면 도구로 정의합니다. 모델이 참조하기만 하면 되는 데이터이면 리소스로 정의합니다. 사용자가 대화의 시작점으로 선택하는 지시문이면 프롬프트로 정의합니다.

알아야 할 용어

이 문서 전반에서 반복적으로 사용하는 용어입니다.

MCP (Model Context Protocol)
AI 모델과 외부 시스템 간의 통신 방식을 정의한 프로토콜입니다. Anthropic이 공개했으며, 이 문서는 프로토콜 버전 2026-07-28을 기준으로 합니다.
backend
도구·리소스·프롬프트를 실제로 제공하는 구현체입니다. 하나의 서버에 여러 개를 등록할 수 있으며, mcpj가 이를 통합해 클라이언트에게 단일 서버로 노출합니다.
transport
메시지를 전달하는 계층입니다. HTTP를 사용하는 방식과, 프로세스를 실행해 표준 입출력으로 통신하는 stdio 방식을 지원합니다.
gateway
여러 backend를 후단에 두고 단일 엔드포인트로 노출하는 구성입니다. 이 라이브러리는 해당 구성을 지원하지만, backend를 하나만 등록해 일반적인 서버로 사용해도 됩니다.
capability
서버와 클라이언트가 지원 가능한 기능을 상대에게 알리는 선언입니다. 상대가 선언하지 않은 기능을 요구하는 것은 프로토콜 위반입니다.

설치

의존성을 추가합니다. 이 라이브러리는 전이 의존성을 포함하지 않으며, Java 표준 라이브러리만 사용합니다.

pom.xml
<dependency>
  <groupId>com.wangbyul.mcpj</groupId>
  <artifactId>mcpj</artifactId>
  <version>0.1.0</version>
</dependency>
build.gradle
implementation 'com.wangbyul:mcpj:0.1.0'

Spring Boot 환경에서는 starter를 사용하면 자동 구성이 적용됩니다.

핵심 JAR만으로 사용 가능한 범위

이 아티팩트 하나로 대부분의 기능을 사용할 수 있습니다. 다음은 클래스패스에 이 JAR만 두고 Java 8·11·17·21·25에서 각 기능을 실행해 확인한 결과입니다.

구분항목추가 의존성
backend SimpleBackend · AnnotatedBackend · RestBackend · OpenApiBackend · McpBackend 없음
backend JdbcBackend JDBC 드라이버
전송 내장 HTTP 서버 · stdio 서버 · McpEndpoint 없음
전송 서블릿 어댑터 서블릿 API
클라이언트 HTTP 접속 · stdio 접속 · 구독 · 자동완성 없음
운영 OAuth 2.1 검증 · 감사 기록 · 호출 빈도 제한 · Tasks 확장 · 변경 알림 없음
통합 Spring Boot 자동 구성 starter 아티팩트
추가 의존성이 필요한 세 항목

서블릿 어댑터jakarta.servlet 또는 javax.servlet API를 요구합니다. module-inforequires static으로 선언되어 있어, API가 없으면 해당 어댑터 클래스만 로드되지 않고 나머지 기능은 정상 동작합니다. 서블릿 컨테이너에 배포하는 환경에서는 컨테이너가 이 API를 제공하므로 별도 조치가 필요하지 않습니다.

Spring Boot 자동 구성mcpj-spring-boot-starter에 포함되어 있습니다. Spring을 사용하지 않는 애플리케이션에 Spring 의존성이 전이되지 않도록 분리한 것이며, starter는 핵심 JAR을 전이 의존성으로 포함합니다.

JdbcBackendjava.sql만으로 구성할 수 있으나, 실제 연결에는 대상 데이터베이스의 JDBC 드라이버가 필요합니다.

첫 서버 만들기

도구 하나를 등록한 서버를 실행하고 클라이언트가 이를 호출하는 과정입니다. 필요한 코드는 다음이 전부입니다.

Java
import com.wangbyul.mcpj.McpServer;
import com.wangbyul.mcpj.protocol.ToolResult;

public class Main {
    public static void main(String[] args) throws Exception {
        McpServer server = McpServer.named("재고 서버")
                .tool("현재_시각", "서버의 현재 시각을 알려 준다",
                        call -> ToolResult.text(java.time.LocalDateTime.now().toString()))
                .build();

        String url = server.listen(8080);
        System.out.println("떴습니다: " + url);
    }
}

실행하면 http://127.0.0.1:8080/mcp에 엔드포인트가 노출됩니다.

터미널 — 도구 목록 물어보기
curl -s http://127.0.0.1:8080/mcp \
  -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{
        "_meta":{
          "io.modelcontextprotocol/protocolVersion":"2026-07-28",
          "io.modelcontextprotocol/clientCapabilities":{}
        }}}'
필수 헤더

프로토콜 버전 2026-07-28은 MCP-Protocol-VersionMcp-Method 헤더를 필수로 규정합니다. 요청 본문의 _meta에도 프로토콜 버전과 클라이언트 capability를 포함해야 합니다. 중간 프록시나 방화벽이 본문을 파싱하지 않고도 요청의 성격을 판별할 수 있도록 하기 위한 규정입니다.

다음에 무엇을 할지

01

도구 정의하기

파라미터 정의, 구조화된 결과 반환, 실패 처리를 다룹니다.

02

기존 서비스 노출하기

메서드에 애노테이션을 선언하면 도구가 됩니다. 코드 이전이 필요하지 않습니다.

03

기존 REST API 연동하기

OpenAPI 문서로부터 도구를 자동 생성합니다.

04

Spring Boot에 통합하기

starter 의존성과 설정만으로 구성이 완료됩니다.

도구

모델이 스스로 판단해 호출하는 대상입니다. 입력과 출력을 명확히 기술해야 모델이 올바르게 사용합니다.

인자를 받는 도구

Java
import com.wangbyul.mcpj.backend.SimpleBackend;
import com.wangbyul.mcpj.json.Json;
import com.wangbyul.mcpj.protocol.ToolResult;
import com.wangbyul.mcpj.protocol.ToolSpec;

Backend inventory = SimpleBackend.id("inventory")
        .tool(ToolSpec.named("check_stock")
                      .description("상품 코드로 현재 재고 수량을 조회한다. "
                                 + "수량과 창고 위치를 함께 돌려준다.")
                      .parameter("sku", "string", "상품 코드. 예: A-1024", true)
                      .readOnly()
                      .build(),
              call -> {
                  String sku = call.requiredString("sku");
                  return ToolResult.structured(Json.obj(
                          "sku", sku,
                          "quantity", inventoryService.count(sku),
                          "warehouse", inventoryService.location(sku)));
              })
        .build();
description 작성 지침

모델이 도구의 사용 시점을 판단하는 근거는 description이 유일합니다. “재고 조회”와 같은 단문보다 입력과 출력을 구체적으로 기술할수록 선택 정확도가 높아집니다.

인자를 읽는 방법

ToolCall이 타입 변환과 필수 여부 검사를 수행합니다. Map을 직접 조회할 필요가 없습니다.

메서드값이 없는 경우돌려주는 것
requiredString("sku")예외를 발생시킵니다String
string("memo")nullString
string("memo", "기본값")지정한 기본값String
requiredInteger("qty")오류를 냅니다long
integer("page", 1)기본값long
number("rate", 0.0)기본값double
bool("dryRun", false)기본값boolean
object("filter")nullMap
array("ids")nullList

결과를 돌려주는 방법

만드는 법쓰임
ToolResult.text("...") 자연어 문자열. 모델이 그대로 읽습니다
ToolResult.structured(Json.obj(...)) 구조화된 값. 객체·배열·문자열·수를 모두 허용합니다
ToolResult.failure("...") 도구 실행 실패를 알립니다. 모델이 이를 인지하고 다른 방법을 시도합니다
ToolResult.ofBlocks(...) 이미지나 리소스 링크를 함께 반환할 때 사용합니다
도구 실패는 예외가 아니라 결과입니다

“재고가 부족함”은 도구가 정상적으로 수행된 결과이며 서버 오류가 아닙니다. ToolResult.failure(...)로 반환하면 모델이 이를 인지하고 다른 방법을 시도합니다. 예외를 던지면 JSON-RPC 오류로 변환되어 모델이 내용을 확인할 수 없습니다.

결과의 모양을 미리 알리기

outputSchema를 선언하면 클라이언트가 결과 처리 방식을 사전에 결정할 수 있습니다. mcpj는 결과가 스키마를 준수하는지 검증하며, 불일치 시 경고를 기록합니다. 요청을 실패로 처리하지는 않습니다.

Java
ToolSpec.named("check_stock")
        .description("...")
        .parameter("sku", "string", "상품 코드", true)
        .outputSchema(Json.obj(
                "type", "object",
                "properties", Json.obj(
                        "quantity", Json.obj("type", "integer"),
                        "warehouse", Json.obj("type", "string"))))
        .build()

권한이 필요한 도구

requireScope(...)를 선언하면 해당 권한이 없는 요청에는 도구가 목록에 포함되지 않습니다. 목록에 노출된 도구가 호출 시점에 실패하면 모델이 반복해서 시도하므로, 처음부터 제외하는 편이 적절합니다.

Java
ToolSpec.named("refund")
        .description("주문을 환불한다")
        .parameter("orderId", "string", "주문 번호", true)
        .requireScope("orders:write")
        .build()

리소스

모델이 참조하는 데이터입니다. 실행 대상이 아니라 조회 대상이므로, 사용자 또는 클라이언트가 선택해 모델의 컨텍스트에 포함시킵니다.

고정 리소스

URI 형식은 자유롭게 정할 수 있으나 스킴:///경로 형태를 권장합니다. 클라이언트는 이 URI로 내용을 요청합니다.

Java
SimpleBackend.id("docs")
        .resource("docs:///반품정책.md", "반품 정책", "text/markdown",
                  uri -> policyRepository.latestMarkdown())
        .build()

인자를 받는 리소스 — 템플릿

대상 수가 많아 전체를 열거할 수 없는 경우에 사용합니다. 중괄호로 변수 위치를 선언하면 클라이언트가 값을 채워 실제 URI를 구성한 뒤 요청합니다. 읽기 구현에는 값이 채워진 URI가 그대로 전달됩니다.

Java
SimpleBackend.id("orders")
        .resourceTemplate("orders:///{orderId}", "주문 상세", "application/json",
                          uri -> orderRepository.findJson(uri))
        .build()
표기치환 범위매칭되는 URI 예
{name} 경로 세그먼트 하나. 슬래시를 포함하지 않습니다 orders:///A-100
{+name} 복수 세그먼트. 슬래시를 포함합니다 files:///a/b/c.txt
{/name} 선행 슬래시가 리터럴로 취급됩니다 docs:///wiki/Home
지원하지 않는 템플릿은 구성 시점에 거부합니다

{a,b}{path*}처럼 지원하지 않는 표기를 사용하면 서버 구성 시점에 예외가 발생합니다. 이를 허용하면 목록에는 노출되지만 읽을 수 없는 리소스가 되고, 운영 중에야 문제가 드러납니다. 표기법은 RFC 6570을 따릅니다.

프롬프트

사용자가 목록에서 선택해 사용하는 사전 정의 지시문입니다. 도구와 달리 모델이 직접 호출하지 않으며, 사용자가 선택합니다.

Java
SimpleBackend.id("review")
        .prompt("코드검토", "우리 팀 규칙에 맞춰 코드를 검토한다",
                arguments -> "다음 코드를 검토해 줘. 우리 규칙은 "
                           + conventions.summary() + " 이다.")
        .build()

인자를 받는 프롬프트

파라미터를 선언하면 클라이언트가 사용자로부터 값을 입력받아 전달합니다. 도구와 달리 JSON Schema가 아니라 이름·설명·필수 여부의 목록 형태로 선언합니다.

Java
.prompt("번역", "문서를 지정한 언어로 옮긴다",
        Collections.singletonList(Json.obj(
                "name", "language",
                "description", "옮길 언어",
                "required", true)),
        arguments -> arguments.get("language") + "로 옮겨 줘")

애노테이션으로 노출하기

기존 서비스 클래스에 애노테이션을 선언하는 방식입니다. HTTP를 경유하지 않고 동일 프로세스 내에서 메서드를 직접 호출하므로 트랜잭션과 보안 컨텍스트가 그대로 유지됩니다.

Java — 서비스 쪽
import com.wangbyul.mcpj.backend.annotation.*;

@Service
public class OrderService {

    @McpTool(description = "주문 번호로 주문 상세를 조회한다", readOnly = true)
    public Map<String, Object> findOrder(
            @McpParam(name = "orderId", description = "주문 번호") String orderId) {
        return repository.findAsMap(orderId);
    }

    @McpResource(uri = "orders:///{orderId}", name = "주문 상세",
                 mimeType = "application/json")
    public String orderDocument(
            @McpParam(name = "orderId", description = "주문 번호") String orderId) {
        return repository.findJson(orderId);
    }

    @McpPrompt(description = "이 주문의 문제를 정리한다")
    public String troubleshoot(
            @McpParam(name = "orderId", description = "주문 번호") String orderId) {
        return "주문 " + orderId + " 에 어떤 문제가 있는지 정리해 줘";
    }
}
Java — 등록
Backend orders = AnnotatedBackend.id("orders")
        .expose(orderService)
        .expose(customerService)
        .build();
파라미터 이름은 명시해야 합니다

Java는 컴파일 과정에서 메서드 파라미터 이름을 보존하지 않습니다. 따라서 @McpParam(name = "orderId")와 같이 이름을 명시해야 합니다. 누락 시 서버 구성 시점에 예외가 발생하므로 운영 중에 문제가 드러나지는 않습니다.

반환 타입별 처리

애노테이션반환 타입결과
@McpToolToolResult그대로 사용합니다. 결과를 가장 정밀하게 제어할 수 있습니다
Map구조화된 결과로 변환됩니다
Listitems로 감싼 구조화 결과로 변환됩니다
String·숫자·boolean텍스트 결과로 변환됩니다
@McpResourceString본문으로 사용하며 mimeType을 함께 반환합니다
byte[]Base64로 인코딩해 반환합니다. 이미지나 PDF에 사용합니다
Map·List명세 형식으로 간주해 그대로 사용합니다
@McpPromptString사용자 메시지 하나로 변환됩니다
List다중 턴 대화를 그대로 전달합니다

호출 문맥

호출 주체, 진행 상황 통지, 요청 취소 여부를 다룹니다. 도구 구현에서 call.context()로 얻습니다.

Java
call -> {
    CallContext ctx = call.context();

    // 누가 불렀나
    String who = ctx.principal();
    if (!ctx.hasScope("orders:write")) {
        return ToolResult.failure("권한이 없습니다");
    }

    for (int i = 0; i < total; i++) {
        // 클라이언트가 포기했으면 그만둔다
        ctx.ensureNotCancelled();

        // 진행 상황을 알린다. 값은 반드시 늘어나야 한다
        ctx.progress(i, (double) total, i + "번째 처리 중");
        ctx.log("info", "처리: " + items.get(i));

        process(items.get(i));
    }
    return ToolResult.text("완료");
}
메서드설명
principal()인증된 주체 식별자. 인증을 구성하지 않은 경우 null
hasScope("...")이 요청이 해당 scope를 보유하는지 확인합니다
progress(값, 전체, 문구)진행 상황을 통지합니다
log(수준, 문구)로그를 클라이언트로 전송합니다
isCancelled()클라이언트가 요청을 취소했는지 확인합니다
ensureNotCancelled()취소된 경우 예외를 던져 처리를 중단합니다
header("...")원본 HTTP 헤더를 반환합니다
supportsTasks()작업으로 접수할 수 있는 요청인지
진행 상황과 로그가 전송되지 않는 조건

클라이언트가 요청에 progressToken을 포함하지 않으면 진행 상황을 전송하지 않습니다. 로그 역시 io.modelcontextprotocol/logLevel이 없으면 전송하지 않습니다. 명세가 이를 금지하기 때문입니다. 요청하지 않은 알림은 클라이언트가 처리할 수 없습니다. 호출 코드는 그대로 두어도 되며, 해당 조건에서는 아무 동작도 수행하지 않습니다.

추가 입력 요청 (elicitation)

도구를 처리하는 중에 사용자 입력이 필요한 경우가 있습니다. 저장 위치 선택, 삭제 확인, 결제 완료 여부 등이 이에 해당합니다. 서버가 요청을 완료하지 않고 입력 요청을 반환하면, 클라이언트가 입력을 받아 동일한 요청을 재전송합니다.

동일한 이름의 클래스가 두 개 존재합니다

서버에서 입력을 요청할 때 사용하는 것은 com.wangbyul.mcpj.backend.InputRequiredException입니다. com.wangbyul.mcpj.client.InputRequiredException은 이름이 같지만 반대 방향으로, 클라이언트가 서버로부터 입력 요청을 수신했을 때 던지는 예외입니다. 두 패키지를 *로 함께 import하면 참조가 모호해져 컴파일에 실패하므로, 사용하는 쪽을 정규화된 이름으로 지정하거나 하나만 import합니다.

폼 모드 — 값 입력받기

Java
call -> {
    Object answer = call.context().inputResponses().get("folder");

    if (answer == null) {
        // 아직 답이 없다. 물어본다.
        throw InputRequiredException.elicit("folder",
                "어느 폴더에 저장할까요?",
                Json.obj("type", "object", "properties",
                        Json.obj("folder", Json.obj("type", "string"))),
                Json.obj("단계", "폴더선택"));   // 다음 회차까지 들고 갈 문맥
    }

    // 답이 왔다. 이어서 처리한다.
    String folder = Json.strAt(answer, "content", "folder");
    return ToolResult.text(folder + " 에 저장했습니다");
}

URL 모드 — 외부 페이지로 위임하기

결제나 외부 서비스 로그인처럼 폼으로 처리할 수 없는 경우에 사용합니다. 클라이언트가 해당 URL을 브라우저로 열고, 사용자가 절차를 완료하면 원래 요청을 재전송합니다.

Java
String paymentId = payments.begin(orderId);
throw InputRequiredException.elicitUrl("pay",
        "결제를 마쳐 주세요",
        payments.checkoutUrl(paymentId),
        Json.obj("paymentId", paymentId));   // 다음 회차에 이것으로 대조한다
클라이언트가 선언한 모드만 요청할 수 있습니다

URL 모드를 지원하지 않는 클라이언트에 URL을 전달하면 처리가 중단됩니다. 따라서 mcpj는 클라이언트가 elicitation.url을 선언한 경우에만 이 요청을 전송하며, 선언하지 않았으면 -32021로 거부합니다. 폼 모드는 elicitation 선언만으로 사용할 수 있습니다.

응답은 회차마다 새 값만 전달됩니다

두 번 이상 입력을 요청하는 경우, 이전 회차에서 받은 응답은 다음 요청에 다시 포함되지 않습니다. 필요한 값은 stateToPreserve에 저장하고 context().restoredState()로 조회합니다. 서버는 상태를 유지하지 않으므로, 회차 간 전달할 값은 서명된 requestState에 담겨 왕복합니다.

오래 걸리는 작업

수 분이 소요되는 작업은 연결을 유지한 채 처리할 수 없습니다. 작업 핸들을 먼저 반환하고 비동기로 처리한 뒤, 클라이언트가 해당 식별자로 진행 상황을 조회하도록 합니다.

Java — 서버 준비
McpEngine engine = McpEngine.builder()
        .name("보고서 서버")
        .backend(reports)
        .tasks(new TaskManager())   // 이것을 켜야 씁니다
        .build();
Java — 도구 안에서
call -> {
    // 클라이언트가 이 확장을 선언했을 때만 작업으로 접수할 수 있다
    if (!call.context().supportsTasks()) {
        return generateSynchronously();   // 모르는 클라이언트는 그냥 기다리게 한다
    }

    return call.context().startTask(progress -> {
        for (int i = 0; i < 100; i++) {
            if (progress.isCancelled()) {
                return ToolResult.text("취소되었습니다");
            }
            progress.report(i + "% 처리했습니다");
            processChunk(i);
        }
        return ToolResult.text("보고서를 만들었습니다");
    });
}

클라이언트는 반환받은 taskId로 진행 상황과 최종 결과를 조회합니다. 작업 관리는 io.modelcontextprotocol/tasks 확장이며, 서버와 클라이언트가 양쪽 모두 이 확장을 선언해야 동작합니다.

Java — 클라이언트 쪽
// 클라이언트도 이 확장을 선언해야 서버가 작업으로 접수합니다
RequestOptions options = RequestOptions.NONE.capabilities(
        Json.obj(Mcp.CAP_EXTENSIONS, Json.obj(Mcp.EXT_TASKS, Json.obj())));

// 도구를 부르면 작업 핸들이 돌아옵니다
ToolResult accepted = client.callTool("generate_report", Json.obj(), options);
String taskId = Json.asString(accepted.toJson().get("taskId"));

// 끝날 때까지 물어봅니다. 확장 선언은 클라이언트가 알아서 붙입니다
while (true) {
    Map<String, Object> status = client.task(taskId);
    if (Mcp.TASK_COMPLETED.equals(Json.asString(status.get("status")))) {
        System.out.println(status.get("result"));
        break;
    }
    Thread.sleep(1000);
}

// 중단하려면
client.cancelTask(taskId);

변경 알림

도구나 리소스 목록이 런타임에 변경된 경우 클라이언트에 통지합니다. 통지하지 않으면 클라이언트는 이전 목록을 계속 사용합니다.

Java
// 목록이 바뀌었다 — 무엇이 바뀌었는지 가려서 알린다
engine.refresh();

// 리소스 하나의 내용이 바뀌었다
engine.notifyResourceChanged("docs:///반품정책.md");

refresh()는 도구·리소스·프롬프트 목록을 재구성한 뒤 실제로 변경된 항목만 통지합니다. 변경되지 않은 목록까지 통지하면 클라이언트가 불필요하게 재조회합니다.

backend가 자체적으로 변경을 감지할 수 있는 경우 Backend.onChanged(Runnable)을 구현하면 refresh()가 자동으로 호출됩니다.

REST API 연동

OpenAPI 문서가 있으면 도구를 직접 정의할 필요가 없습니다. 문서를 파싱해 엔드포인트마다 도구를 자동으로 생성합니다.

Java
McpServer server = McpServer.named("CRM 게이트웨이")
        .exposeOpenApi("https://crm.example.com/openapi.json",
                       "/customers/**", "/orders/**")   // 내보낼 경로만 고른다
        .build();
Java — 더 세밀하게
Backend crm = OpenApiBackend.id("crm")
        .documentUrl("https://crm.example.com/openapi.json")
        .baseUrl("https://crm.example.com")
        .include("/customers/**")
        .exclude("/customers/*/delete")
        .bearerToken(() -> tokenStore.current())
        .timeoutMs(10000)
        .build();
변환 결과 실측

공개 OpenAPI 문서로 검증한 결과입니다. Petstore 19개, Stripe 446개, GitHub 1,220개의 도구가 생성되었습니다. 도구 수가 많은 경우 include로 대상을 제한하고 pageSize로 목록을 분할하는 것을 권장합니다.

경로 패턴

표기매칭 대상
/customers/**/customers 하위 전체
/orders/*/orders 직하위 한 단계
GET:/orders/**해당 경로의 GET 메서드만

데이터베이스 연동

사전에 정의한 질의만 도구로 노출합니다. 모델이 임의의 SQL을 생성해 실행하는 것은 허용하지 않습니다.

Java
McpServer server = McpServer.named("주문 조회")
        .exposeSql(dataSource,
                SqlQuery.select("find_order",
                                "주문 번호로 주문 한 건을 조회한다")
                        .sql("SELECT id, status, total FROM orders WHERE id = ?")
                        .param("orderId", "string", "주문 번호"))
        .build();
질의를 문자열로 조립하지 않습니다

파라미터는 PreparedStatement로 바인딩됩니다. 모델이 생성한 값이 SQL 구문으로 해석될 여지가 없습니다. 모델이 SQL을 직접 작성하는 방식은 지원하지 않습니다.

다른 MCP 서버 중계

운영 중인 MCP 서버를 후단에 두고 단일 엔드포인트로 노출합니다. 여러 팀이 각각 구축한 서버를 하나의 주소로 통합할 때 사용합니다.

HTTP로 떠 있는 서버

Java
Backend crm = McpBackend.id("crm")
        .endpoint("https://crm.example.com/mcp")
        .bearerToken(() -> tokenStore.current())
        .build();

stdio 전송 서버

상당수의 MCP 서버는 포트를 열지 않고 프로세스로 실행되어 파이프로 통신합니다. 로컬 파일이나 데이터베이스를 다루는 서버가 주로 이 방식입니다. 이러한 서버를 gateway 후단에 배치하면 외부에는 HTTP로 노출되고 내부에서는 프로세스로 동작하는 구성이 됩니다.

Java
Backend files = McpBackend.id("files")
        .launch("npx", "-y", "@modelcontextprotocol/server-filesystem", "/데이터")
        .env("LOG_LEVEL", "debug")
        .build();

실행된 프로세스는 backend를 종료할 때 함께 정리됩니다. endpoint(...)launch(...)는 배타적이며, 둘 다 지정하면 서버 구성 시점에 예외가 발생합니다.

도구 이름 충돌

서로 다른 backend가 동일한 도구 이름을 사용하면 양쪽 모두에 backend 식별자가 접두사로 부여됩니다. crmerp가 각각 search를 제공하면 crm_searcherp_search가 됩니다. 한쪽에만 접두사를 부여하면 backend를 추가하거나 제거하는 것만으로 기존 도구 이름이 변경됩니다.

배치 방식

기존 환경에 맞는 방식을 선택합니다. 어느 방식을 선택하더라도 동일한 엔진이 동작합니다.

1. 내장 HTTP 서버

별도의 웹 서버가 없는 환경에서 사용합니다. JDK에 포함된 HTTP 서버를 사용합니다.

Java
String url = server.listen(8080);   // http://127.0.0.1:8080/mcp

2. stdio 전송

데스크톱 클라이언트가 이 프로그램을 자식 프로세스로 실행하는 방식입니다. 포트를 개방하지 않습니다.

Java
server.listenOnStdio();   // 입력이 끝날 때까지 돌아오지 않습니다

3. 서블릿

Tomcat이나 Jetty에서 운영 중인 애플리케이션에 통합할 때 사용합니다.

Java
import com.wangbyul.mcpj.transport.servlet.jakarta.McpServlet;

// Tomcat 10+ · Jetty 11+ (jakarta.servlet)
// 서블릿은 McpServer 가 아니라 그 안의 endpoint 를 받습니다.
context.addServlet("mcp", new McpServlet(server.endpoint()))
       .addMapping("/mcp");

// Tomcat 9 이하 (javax.servlet) 라면 패키지만 다릅니다
//   com.wangbyul.mcpj.transport.servlet.McpServlet

4. 임의의 프레임워크

McpRequest를 전달하고 McpResponse를 반환받는 방식입니다. 예외를 외부로 전파하지 않으며, 인증 실패와 프로토콜 위반을 포함한 모든 오류가 적절한 상태 코드와 본문을 담은 응답으로 반환됩니다.

Java
McpResponse response = server.handle(
        McpRequest.post()
                .headers(요청헤더)
                .body(요청본문)
                .build());

if (response.isStream()) {
    response.writeStreamTo(출력스트림);   // 스트림이 끝날 때까지 돌아오지 않습니다
} else {
    쓰기(response.status(), response.headers(), response.body());
}

Spring Boot

starter를 추가하면 애노테이션이 선언된 빈을 탐색해 등록하고, 엔드포인트를 노출하며, Spring Security와 연동합니다.

pom.xml
<dependency>
  <groupId>com.wangbyul.mcpj</groupId>
  <artifactId>mcpj-spring-boot-starter</artifactId>
  <version>0.1.0</version>
</dependency>
application.yml
mcpj:
  name: 주문 서비스
  path: /mcp
  instructions: 주문과 배송을 조회할 수 있습니다
  oauth:
    issuer: https://auth.example.com/realms/main
    resource: https://api.example.com/mcp
  required-scopes: [ orders:read ]

@McpTool·@McpResource·@McpPrompt가 선언된 메서드를 가진 빈은 자동으로 등록됩니다. 직접 구현한 Backend 빈이 있으면 함께 등록됩니다.

설정기본값설명
mcpj.enabledtruefalse이면 엔드포인트를 노출하지 않습니다
mcpj.path/mcp엔드포인트 경로
mcpj.page-size00이면 목록을 분할하지 않습니다
mcpj.list-cache-ttl60s클라이언트에 통지할 캐시 유효 기간
mcpj.accept-legacy-clientstrue이전 프로토콜 버전 클라이언트를 수용합니다
mcpj.validate-tool-argumentstrue도구 파라미터가 스키마를 준수하는지 검증합니다
mcpj.expose-backend-errorsfalsetrue이면 backend 오류 내용을 그대로 반환합니다
mcpj.tasks-enabledfalse장기 실행 작업 확장을 활성화합니다

인증

OAuth 2.1 리소스 서버로 동작합니다. 토큰을 발급하지 않으며, 수신한 토큰의 유효성을 검증합니다.

Java
McpServer server = McpServer.named("주문 서비스")
        .expose(orderService)
        .oauth("https://auth.example.com/realms/main",   // 인가 서버
               "https://api.example.com/mcp")             // 이 서버의 주소
        .requireScope("orders:read")
        .build();

검증 항목

  • 서명 — 인가 서버의 공개키로 검증합니다. none 알고리즘과 대칭키는 거부합니다
  • 만료exp는 필수이며, 서버 간 시각 오차를 허용합니다
  • 발급자iss가 구성된 인가 서버와 일치하는지 확인합니다
  • 대상aud에 이 서버의 식별자가 포함되어 있는지 확인합니다. 다른 서버용으로 발급된 토큰의 전용을 차단합니다
  • 권한scope에 요구 권한이 포함되어 있는지 확인합니다
평문 HTTP 인가 서버는 거부합니다

공개키를 평문으로 조회하면 중간자 공격으로 키를 치환해 토큰을 위조할 수 있습니다. 따라서 https가 아닌 주소는 서버 구성 시점에 거부합니다. 로컬 개발용 주소(127.0.0.1, localhost)만 예외로 허용합니다.

인가 실패 응답

토큰이 없거나 유효하지 않으면 401과 함께 WWW-Authenticate 헤더를 반환합니다. 클라이언트는 이 헤더에 지정된 주소에서 보호 리소스 메타데이터 문서를 조회해 사용할 인가 서버를 판별합니다. 규격이 정한 경로는 /.well-known/oauth-protected-resource이며, 해당 문서는 server.metadataDocument()로 얻습니다.

운영 설정

감사 기록, 호출 빈도 제한, 목록 분할을 다룹니다. 필요한 항목만 선택적으로 활성화합니다.

Java
McpEngine engine = McpEngine.builder()
        .name("주문 서비스")
        .backend(orders)

        // 누가 무엇을 언제 불렀는지 남긴다
        .auditLog(AuditLog.TO_JAVA_LOGGING)

        // 사용자별로 1분에 60번까지
        .rateLimiter(RateLimiter.perPrincipal(60, 60000))

        // 도구가 많으면 목록을 나눠 준다
        .pageSize(50)

        // 클라이언트에게 알릴 캐시 유효 기간
        .listCacheTtl(60000)

        // backend 오류 내용을 그대로 내보낼지 (기본 꺼짐)
        .exposeBackendErrors(false)
        .build();
운영 환경에서는 비활성화하십시오

exposeBackendErrors를 활성화하면 데이터베이스 오류 메시지나 내부 경로가 클라이언트에 그대로 노출됩니다. 개발 환경에서만 사용하고 운영 환경에서는 비활성화해야 합니다. 기본값이 비활성화인 이유입니다.

충돌로 제외된 항목 확인

여러 backend가 동일한 리소스 URI를 사용하면 먼저 등록된 항목만 유지되고 나머지는 제외됩니다. 로그에 기록되지만 확인이 누락되기 쉬우므로 헬스 체크에서 조회할 수 있도록 제공합니다.

Java
List<String> conflicts = engine.resourceConflicts();
if (!conflicts.isEmpty()) {
    health.degraded("리소스가 겹쳐 일부가 빠졌습니다", conflicts);
}

MCP 서버 접속

이 라이브러리는 서버와 함께 클라이언트도 제공합니다. 외부 MCP 서버에 접속해 도구를 호출할 때 사용합니다.

Java — HTTP 서버
McpClient client = McpClient.connect("https://api.example.com/mcp")
        .bearerToken(() -> tokenStore.current())
        .identifyAs("우리 앱", "1.0")
        .build();

for (ToolSpec tool : client.tools()) {
    System.out.println(tool.name() + " — " + tool.description());
}

ToolResult result = client.callTool("check_stock", Json.obj("sku", "A-1024"));
System.out.println(result.text());

client.close();
Java — 프로그램으로 띄우는 서버 (stdio)
McpClient client = McpClient.launch(
                "npx", "-y", "@modelcontextprotocol/server-filesystem", "/데이터")
        .env("LOG_LEVEL", "debug")
        .build();

// close() 하면 띄운 프로그램도 함께 정리됩니다

제공 메서드

메서드하는 일
discover()서버의 지원 기능과 프로토콜 버전을 조회합니다
tools()도구 목록을 조회합니다
callTool(이름, 인자)도구를 호출합니다
resources() · resourceTemplates()리소스와 리소스 템플릿 목록을 조회합니다
readResource(주소)리소스 내용을 조회합니다
prompts() · getPrompt(이름, 인자)프롬프트 목록 조회 및 렌더링
completePrompt(...) · completeResource(...)파라미터 값 자동완성
task(식별자) · cancelTask(식별자)오래 걸리는 작업의 진행 상황 조회와 중단
listen()변경 소식을 받습니다
send(메서드, 인자)임의의 메서드를 직접 호출합니다
프로토콜 버전은 자동으로 협상합니다

대상 서버가 2026-07-28을 지원하는지 이전 버전인지는 첫 요청에서 판별합니다. 이전 버전이면 initialize 핸드셰이크를 대신 수행하며, 이후에는 동일한 메서드로 사용할 수 있습니다. 버전을 이미 알고 있다면 protocolVersion(...)으로 고정해 왕복 한 번을 줄일 수 있습니다.

변경 구독

서버가 클라이언트에 먼저 메시지를 전송하는 유일한 경로입니다. 수신할 알림 종류를 선언하면 서버가 해당 이벤트 발생 시마다 전송합니다.

Java
Subscription listening = client.listen()
        .toolsListChanged()
        .resourcesListChanged()
        .resource("docs:///반품정책.md")      // 이 문서의 내용이 바뀌면
        .start((method, params) -> {
            if (Mcp.N_TOOLS_LIST_CHANGED.equals(method)) {
                try {
                    cachedTools = client.tools();
                } catch (IOException failed) {
                    // 소식은 별도 스레드에서 옵니다. 여기서 던지면 아무도 받지
                    // 못하므로 이 자리에서 처리해야 합니다. 구독은 그대로 살아 있습니다.
                    System.err.println("목록을 다시 받아 오지 못했습니다: " + failed);
                }
            }
        });

// 서버가 무엇을 받아 주기로 했는지 확인할 수 있습니다
Map<String, Object> honored = listening.acknowledgement(5000);

listening.close();
구독 대상전송 시점
toolsListChanged()사용 가능한 도구가 추가되거나 제거된 경우
resourcesListChanged()조회 가능한 리소스가 추가되거나 제거된 경우
promptsListChanged()선택 가능한 프롬프트가 변경된 경우
resource("주소")해당 URI의 내용이 변경된 경우
tasks()진행 중인 작업의 상태가 변경된 경우
자동 재연결을 수행하지 않습니다

재연결 시점과 방식은 애플리케이션 정책에 따라 달라지므로 자동으로 재접속하지 않습니다. 연결 종료 여부는 isOpen()으로, 종료 원인은 failure()로 확인합니다. 호출 측에서 명시적으로 종료한 경우 failure()null입니다.

호환 범위

자바

단일 JAR가 Java 8부터 26까지 동작합니다. 멀티 릴리스 JAR 구조로 여러 계층을 포함하며, 실행 중인 Java 버전에 맞는 구현이 자동으로 선택됩니다.

자바외부 HTTP 호출동시성요청 컨텍스트
8 – 10HttpURLConnection고정 크기 스레드 풀ThreadLocal
11 – 20HttpClient고정 스레드 풀ThreadLocal
21 – 24HttpClient가상 스레드ThreadLocal
25 +HttpClient가상 스레드ScopedValue

MCP 규약

프로토콜 버전 2026-07-28을 구현합니다. 이전 버전 클라이언트도 수용하며, initialize 핸드셰이크를 대신 처리하고 해당 버전에 존재하지 않는 필드는 제거한 뒤 응답합니다. 이 동작을 비활성화하려면 acceptLegacyClients(false)를 사용합니다.

수용하는 이전 버전: 2025-11-25 · 2025-06-18 · 2025-03-26 · 2024-11-05 · 2024-10-07

배포 형태

Java 버전별로 아티팩트를 나누지 않고 단일 좌표로 배포합니다. JAR 내부에 META-INF/versions/ 계층을 포함하며, JVM이 실행 중인 버전에 맞는 구현을 선택합니다. 이 방식은 spring-core·jackson-core· byte-buddy·logback-core·slf4j-api 등이 사용하는 것과 동일합니다.

fat JAR 로 패키징하는 경우

maven-shade-plugin으로 패키징할 때 매니페스트에 Multi-Release: true가 유지되어야 계층 선택이 동작합니다. shade 플러그인 3.6.0 기준으로 ManifestResourceTransformer에 이 항목을 지정하면 됩니다. Spring Boot의 repackage는 별도 조치가 필요하지 않습니다.

오류 코드

클라이언트가 수신하는 JSON-RPC 오류 코드와 이에 대응하는 HTTP 상태 코드입니다.

코드이름HTTP발생 조건
-32700Parse error400요청 본문이 유효한 JSON이 아닙니다
-32600Invalid request400JSON-RPC 형식을 따르지 않습니다
-32601Method not found404지원하지 않는 메서드입니다
-32602Invalid params400파라미터가 유효하지 않습니다. 존재하지 않는 도구·리소스·프롬프트도 이 코드로 응답합니다
-32603Internal error200서버 내부 오류입니다. 전송 자체는 성공했으므로 200으로 응답하고 본문에 오류를 포함합니다
429Rate limited429호출 빈도 제한을 초과했습니다. rateLimiter를 구성한 경우에만 발생합니다
-32020Header mismatch400헤더와 본문의 값이 일치하지 않습니다
-32021Missing client capability400클라이언트가 선언하지 않은 capability가 필요합니다
-32022Unsupported protocol version400서버가 지원하지 않는 프로토콜 버전입니다
이전 버전과의 차이

리소스를 찾을 수 없는 경우 이전에는 -32002를 사용했으나 2026-07-28부터 -32602를 사용합니다. JSON-RPC 규격과의 정합성을 위한 변경입니다.

검증 내역

검증 대상과 방법입니다. 모의 객체가 아니라 실제로 동작하는 구성요소를 대상으로 검증합니다.

항목내용
테스트1,048개 통과 · 실패 0
자바 런타임8 – 26 전 19개 버전 · 171개 검사 · 실패 0
규약 상호운용공식 MCP TypeScript SDK 2.0.0 (클라이언트·서버 양쪽)
중계 사슬공식 서버 SDK → 이 게이트웨이 → 공식 클라이언트 SDK
인가 서버Keycloak 26.7. 실제 발급 토큰으로 검증
데이터베이스PostgreSQL 18 · H2
서블릿 컨테이너Jetty 11 · Tomcat 9 · Tomcat 7 (Java 8)
OpenAPI 변환Petstore · Stripe · GitHub 실제 문서
런타임 의존성0개. 빌드 단계에서 강제합니다
단일 JAR 사용 범위Java 8·11·17·21·25에서 핵심 JAR만으로 16개 기능 실행 확인
fat JAR 패키징shade 로 패키징 후 Java 8 – 26 에서 계층 선택 확인