자습서: Azure Container Apps에 Java MCP 서버 배포

이 자습서에서는 Spring Boot 및 MCP Java SDK를 사용하여 작업 관리 도구를 노출하는 MCP(모델 컨텍스트 프로토콜) 서버를 빌드합니다. 서버를 Azure Container Apps에 배포하고 VS Code의 GitHub Copilot 채팅에서 연결합니다.

이 자습서에서는 다음을 수행합니다.

  • MCP 도구를 노출하는 Spring Boot 앱 만들기
  • GitHub Copilot를 사용하여 로컬로 MCP 서버 테스트
  • Azure Container Apps에 앱 컨테이너화 및 배포
  • 배포된 MCP 서버에 GitHub Copilot 연결

필수 조건

앱 스캐폴드 만들기

이 섹션에서는 MCP Java SDK를 사용하여 새 Spring Boot 프로젝트를 만듭니다.

  1. 프로젝트 디렉터리를 만듭니다.

    mkdir tasks-mcp-server && cd tasks-mcp-server
    
  2. 만들기 pom.xml:

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>3.3.0</version>
        </parent>
    
        <groupId>com.example</groupId>
        <artifactId>tasks-mcp-server</artifactId>
        <version>1.0.0</version>
        <name>tasks-mcp-server</name>
        <description>MCP server for task management on Azure Container Apps</description>
    
        <properties>
            <java.version>17</java.version>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>io.modelcontextprotocol.sdk</groupId>
                <artifactId>mcp-spring-webmvc</artifactId>
                <version>0.10.0</version>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </build>
    </project>
    

    pom.xml는 웹 프레임워크를 위한 spring-boot-starter-web 및 MCP SDK를 위한 mcp-spring-webmvc의 두 가지 주요 종속성을 가진 Spring Boot 애플리케이션을 정의합니다. Spring Boot Maven 플러그 인은 앱을 실행 파일 JAR로 패키지합니다.

    비고

    MCP Java SDK는 현재 개발 중입니다. MCP Java SDK 릴리스에서 최신 버전을 확인하고 그에 따라 업데이트합니다<version>.

  3. 디렉터리 구조를 만듭니다.

    mkdir -p src/main/java/com/example/tasksmcp
    mkdir -p src/main/resources
    
  4. 만들기 src/main/resources/application.properties:

    server.port=8080
    

데이터 모델 정의 및 저장

이 섹션에서는 작업 데이터 모델 및 메모리 내 저장소를 정의합니다.

  1. 만들기 src/main/java/com/example/tasksmcp/TaskItem.java:

    package com.example.tasksmcp;
    
    import java.time.Instant;
    
    public class TaskItem {
        private int id;
        private String title;
        private String description;
        private boolean isComplete;
        private Instant createdAt;
    
        public TaskItem(int id, String title, String description, boolean isComplete) {
            this.id = id;
            this.title = title;
            this.description = description;
            this.isComplete = isComplete;
            this.createdAt = Instant.now();
        }
    
        // Getters and setters
        public int getId() { return id; }
        public String getTitle() { return title; }
        public String getDescription() { return description; }
        public boolean isComplete() { return isComplete; }
        public void setComplete(boolean complete) { isComplete = complete; }
        public Instant getCreatedAt() { return createdAt; }
    }
    

    이 클래스는 TaskItem 표준 getter 및 완료 상태에 대한 setter를 사용하여 데이터 모델을 정의합니다. 생성자는 타임스탬프를 자동으로 초기화합니다 createdAt .

  2. 만들기 src/main/java/com/example/tasksmcp/TaskStore.java:

    package com.example.tasksmcp;
    
    import org.springframework.stereotype.Component;
    
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Optional;
    import java.util.concurrent.atomic.AtomicInteger;
    
    @Component
    public class TaskStore {
    
        private final List<TaskItem> tasks = new ArrayList<>();
        private final AtomicInteger nextId = new AtomicInteger(3);
    
        public TaskStore() {
            tasks.add(new TaskItem(1, "Buy groceries", "Milk, eggs, bread", false));
            tasks.add(new TaskItem(2, "Write docs", "Draft the MCP tutorial", true));
        }
    
        public List<TaskItem> getAll() {
            return List.copyOf(tasks);
        }
    
        public Optional<TaskItem> getById(int id) {
            return tasks.stream().filter(t -> t.getId() == id).findFirst();
        }
    
        public TaskItem create(String title, String description) {
            TaskItem task = new TaskItem(nextId.getAndIncrement(), title, description, false);
            tasks.add(task);
            return task;
        }
    
        public Optional<TaskItem> toggleComplete(int id) {
            return getById(id).map(task -> {
                task.setComplete(!task.isComplete());
                return task;
            });
        }
    
        public boolean delete(int id) {
            return tasks.removeIf(t -> t.getId() == id);
        }
    }
    

    TaskStore Spring 구성 요소는 샘플 데이터로 미리 채워진 메모리 내 목록을 관리합니다. 스레드 안전한 ID 생성을 위해 AtomicInteger을 사용하며, 표준 CRUD 작업에 필요한 메서드를 제공합니다.

MCP 도구 정의

이 섹션에서는 AI 모델이 Spring Boot 애플리케이션에서 MCP 서버를 호출하고 구성할 수 있는 MCP 도구를 정의합니다.

  1. 만들기 src/main/java/com/example/tasksmcp/TasksMcpTools.java:

    package com.example.tasksmcp;
    
    import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
    import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
    import io.modelcontextprotocol.spec.McpSchema.TextContent;
    import io.modelcontextprotocol.spec.McpSchema.Tool;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.fasterxml.jackson.databind.node.ObjectNode;
    import org.springframework.stereotype.Component;
    
    import java.util.List;
    import java.util.Map;
    
    @Component
    public class TasksMcpTools {
    
        private final TaskStore store;
        private final ObjectMapper objectMapper = new ObjectMapper();
    
        public TasksMcpTools(TaskStore store) {
            this.store = store;
        }
    
        public List<SyncToolSpecification> getToolSpecifications() {
            return List.of(
                listTasksTool(),
                getTaskTool(),
                createTaskTool(),
                toggleTaskCompleteTool(),
                deleteTaskTool()
            );
        }
    
        private SyncToolSpecification listTasksTool() {
            var tool = new Tool(
                "list_tasks",
                "List all tasks with their ID, title, description, and completion status.",
                emptySchema()
            );
            return new SyncToolSpecification(tool, (exchange, request) -> {
                try {
                    String json = objectMapper.writeValueAsString(store.getAll());
                    return new CallToolResult(List.of(new TextContent(json)), false);
                } catch (Exception e) {
                    return errorResult(e.getMessage());
                }
            });
        }
    
        private SyncToolSpecification getTaskTool() {
            var tool = new Tool(
                "get_task",
                "Get a single task by its numeric ID.",
                objectSchema(Map.of(
                    "task_id", propertySchema("integer", "The numeric ID of the task to retrieve")
                ), List.of("task_id"))
            );
            return new SyncToolSpecification(tool, (exchange, request) -> {
                int taskId = ((Number) request.arguments().get("task_id")).intValue();
                return store.getById(taskId)
                    .map(task -> {
                        try {
                            return new CallToolResult(
                                List.of(new TextContent(objectMapper.writeValueAsString(task))), false);
                        } catch (Exception e) {
                            return errorResult(e.getMessage());
                        }
                    })
                    .orElse(textResult("Task with ID " + taskId + " not found."));
            });
        }
    
        private SyncToolSpecification createTaskTool() {
            var tool = new Tool(
                "create_task",
                "Create a new task with the given title and description. Returns the created task.",
                objectSchema(Map.of(
                    "title", propertySchema("string", "A short title for the task"),
                    "description", propertySchema("string", "A detailed description of what the task involves")
                ), List.of("title", "description"))
            );
            return new SyncToolSpecification(tool, (exchange, request) -> {
                String title = (String) request.arguments().get("title");
                String description = (String) request.arguments().get("description");
                TaskItem task = store.create(title, description);
                try {
                    return new CallToolResult(
                        List.of(new TextContent(objectMapper.writeValueAsString(task))), false);
                } catch (Exception e) {
                    return errorResult(e.getMessage());
                }
            });
        }
    
        private SyncToolSpecification toggleTaskCompleteTool() {
            var tool = new Tool(
                "toggle_task_complete",
                "Toggle a task's completion status between complete and incomplete.",
                objectSchema(Map.of(
                    "task_id", propertySchema("integer", "The numeric ID of the task to toggle")
                ), List.of("task_id"))
            );
            return new SyncToolSpecification(tool, (exchange, request) -> {
                int taskId = ((Number) request.arguments().get("task_id")).intValue();
                return store.toggleComplete(taskId)
                    .map(task -> textResult(
                        "Task " + task.getId() + " is now " + (task.isComplete() ? "complete" : "incomplete") + "."))
                    .orElse(textResult("Task with ID " + taskId + " not found."));
            });
        }
    
        private SyncToolSpecification deleteTaskTool() {
            var tool = new Tool(
                "delete_task",
                "Delete a task by its numeric ID.",
                objectSchema(Map.of(
                    "task_id", propertySchema("integer", "The numeric ID of the task to delete")
                ), List.of("task_id"))
            );
            return new SyncToolSpecification(tool, (exchange, request) -> {
                int taskId = ((Number) request.arguments().get("task_id")).intValue();
                boolean deleted = store.delete(taskId);
                return textResult(deleted
                    ? "Task " + taskId + " deleted."
                    : "Task with ID " + taskId + " not found.");
            });
        }
    
        // Helper methods for JSON Schema construction
        private String emptySchema() {
            return "{\"type\":\"object\",\"properties\":{}}";
        }
    
        private String objectSchema(Map<String, String> properties, List<String> required) {
            ObjectNode schema = objectMapper.createObjectNode();
            schema.put("type", "object");
    
            ObjectNode propsNode = objectMapper.createObjectNode();
            for (var entry : properties.entrySet()) {
                try {
                    propsNode.set(entry.getKey(), objectMapper.readTree(entry.getValue()));
                } catch (Exception e) {
                    propsNode.putObject(entry.getKey()).put("type", "string");
                }
            }
            schema.set("properties", propsNode);
            schema.set("required", objectMapper.valueToTree(required));
    
            return schema.toString();
        }
    
        private String propertySchema(String type, String description) {
            return "{\"type\":\"" + type + "\",\"description\":\"" + description + "\"}";
        }
    
        private CallToolResult textResult(String text) {
            return new CallToolResult(List.of(new TextContent(text)), false);
        }
    
        private CallToolResult errorResult(String message) {
            return new CallToolResult(List.of(new TextContent("Error: " + message)), true);
        }
    }
    

    비고

    MCP Java SDK API 표면이 진화하고 있습니다. 여기에 표시된 도구 등록 패턴은 동기 도구에 사용됩니다 SyncToolSpecification . 도구 등록을 간소화할 수 있는 최신 관용구 및 주석은 MCP Java SDK 설명서를 확인하세요.

  2. 만들기 src/main/java/com/example/tasksmcp/McpConfig.java:

    package com.example.tasksmcp;
    
    import io.modelcontextprotocol.server.McpServer;
    import io.modelcontextprotocol.server.McpSyncServer;
    import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider;
    import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.CorsRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    @Configuration
    public class McpConfig implements WebMvcConfigurer {
    
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            // For production, restrict allowedOrigins to specific trusted domains
            registry.addMapping("/**")
                    .allowedOrigins("*")
                    .allowedMethods("GET", "POST", "DELETE", "OPTIONS")
                    .allowedHeaders("*");
        }
    
        @Bean
        public WebMvcSseServerTransportProvider mcpTransportProvider() {
            return new WebMvcSseServerTransportProvider(new com.fasterxml.jackson.databind.ObjectMapper(), "/mcp");
        }
    
        @Bean
        public McpSyncServer mcpServer(WebMvcSseServerTransportProvider transport, TasksMcpTools tools) {
            McpSyncServer server = McpServer.sync(transport)
                .serverInfo("TasksMCP", "1.0.0")
                .capabilities(ServerCapabilities.builder().tools(true).build())
                .build();
    
            tools.getToolSpecifications().forEach(server::addTool);
    
            return server;
        }
    }
    

    주요 정보:

    • WebMvcSseServerTransportProvider/mcp 경로에서 SSE 전송을 등록합니다.
    • McpServer.sync(transport) 는 도구 기능을 구성하고 각 도구 사양을 등록합니다.
    • VS Code의 GitHub Copilot가 MCP 서버에 대한 원본 간 요청을 하므로 CORS를 사용할 수 있습니다.

    비고

    MCP Java SDK는 안정적인 스트리밍 가능한 HTTP 전송을 아직 제공하지 않으므로 이 자습서에서는 SSE 전송을 사용합니다 WebMvcSseServerTransportProvider . 다른 언어 자습서(.NET, Python, Node.js)는 스트리밍 가능한 HTTP를 사용합니다. Java SDK가 스트리밍 가능한 HTTP 지원을 추가하면 그에 따라 전송 공급자를 업데이트합니다. SSE 전송은 VS Code Copilot 및 기타 MCP 클라이언트와 완벽하게 호환됩니다.

  3. 만들기 src/main/java/com/example/tasksmcp/TasksMcpApplication.java:

    package com.example.tasksmcp;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @SpringBootApplication
    @RestController
    public class TasksMcpApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(TasksMcpApplication.class, args);
        }
    
        @GetMapping("/health")
        public String health() {
            return "healthy";
        }
    }
    

    기본 클래스는 Spring Boot 애플리케이션을 부트스트랩하고 Container Apps 상태 프로브를 위한 /health 엔드포인트를 제공합니다. MCP 엔드포인트는 JSON-RPC 응답을 반환하므로 상태 검사를 위해 별도의 상태 엔드포인트가 필요합니다.

로컬에서 MCP 서버 테스트

Azure에 배포하기 전에 MCP 서버가 로컬로 실행하고 GitHub Copilot에서 연결하여 작동하는지 확인합니다.

  1. 빌드 및 실행:

    mvn spring-boot:run
    
  2. VS Code를 연 다음 , Copilot 채팅 을 열고 에이전트 모드를 선택합니다.

  3. 도구 단추를 선택한 다음, 추가 도구 추가...>MCP 서버를 추가합니다.

  4. HTTP(HTTP 또는 Server-Sent 이벤트)를 선택하고 전송 유형을 묻는 메시지가 표시되면 Server-Sent 이벤트(SSE)를 선택합니다.

    중요합니다

    이 자습서에서는 스트리밍 가능한 HTTP가 아닌 SSE 전송을 사용합니다. 연결이 제대로 작동하려면 VS Code에서 SSE 옵션을 선택해야 합니다.

  5. 서버 URL을 입력합니다. http://localhost:8080/mcp

  6. 서버 ID를 입력합니다. tasks-mcp

  7. 작업 영역 설정을 선택합니다.

  8. "모든 작업을 표시해줘"라고 테스트하십시오.

  9. 코필로트가 MCP 도구 확인을 요청할 때 계속 을 선택합니다.

메모리 내 저장소에서 반환된 작업 목록이 표시됩니다.

팁 (조언)

"PR을 검토할 작업 만들기", "작업 1을 완료로 표시" 또는 "작업 2 삭제"와 같은 다른 프롬프트를 사용해 보세요.

애플리케이션 컨테이너화

Azure에 배포하기 전에 로컬에서 테스트할 수 있도록 애플리케이션을 Docker 컨테이너로 패키지합니다.

  1. 다단계 빌드를 사용하여 Dockerfile를 만듭니다.

    FROM maven:3.9-eclipse-temurin-17-alpine AS build
    WORKDIR /app
    COPY pom.xml .
    RUN mvn dependency:go-offline -B
    COPY src/ src/
    RUN mvn package -DskipTests -B
    
    FROM eclipse-temurin:17-jre-alpine
    WORKDIR /app
    COPY --from=build /app/target/*.jar app.jar
    EXPOSE 8080
    ENTRYPOINT ["java", "-jar", "app.jar"]
    

    다단계 빌드는 Maven 빌드를 런타임 이미지와 분리하여 최종 이미지를 작게 유지합니다.

  2. 로컬로 확인:

    docker build -t tasks-mcp-server .
    docker run -p 8080:8080 tasks-mcp-server
    

    상태 엔드포인트가 응답하는지 확인합니다. curl http://localhost:8080/health

Azure Container Apps에 배포

애플리케이션을 컨테이너화한 후 Azure CLI를 사용하여 Azure Container Apps에 배포합니다. 이 az containerapp up 명령은 클라우드에서 컨테이너 이미지를 빌드하므로 이 단계를 위해 컴퓨터에 Docker가 필요하지 않습니다.

  1. 환경 변수 설정:

    RESOURCE_GROUP="mcp-tutorial-rg"
    LOCATION="eastus"
    ENVIRONMENT_NAME="mcp-env"
    APP_NAME="tasks-mcp-server-java"
    
  2. 리소스 그룹 및 Container Apps 환경을 만듭니다.

    az group create --name $RESOURCE_GROUP --location $LOCATION
    
    az containerapp env create \
        --name $ENVIRONMENT_NAME \
        --resource-group $RESOURCE_GROUP \
        --location $LOCATION
    
  3. 컨테이너 앱을 배포합니다.

    az containerapp up \
        --name $APP_NAME \
        --resource-group $RESOURCE_GROUP \
        --environment $ENVIRONMENT_NAME \
        --source . \
        --ingress external \
        --target-port 8080
    
  4. CORS 구성:

    az containerapp ingress cors enable \
        --name $APP_NAME \
        --resource-group $RESOURCE_GROUP \
        --allowed-origins "*" \
        --allowed-methods "GET,POST,DELETE,OPTIONS" \
        --allowed-headers "*"
    

    비고

    프로덕션의 경우 와일드카드 원본을 신뢰할 수 있는 특정 원본으로 대체합니다. Container Apps에서 보안 MCP 서버를 참조하세요.

  5. 배포 확인:

    APP_URL=$(az containerapp show \
        --name $APP_NAME \
        --resource-group $RESOURCE_GROUP \
        --query "properties.configuration.ingress.fqdn" -o tsv)
    
    curl https://$APP_URL/health
    

배포된 서버에 GitHub Copilot 연결

이제 MCP 서버가 Azure에서 실행 중이므로 배포된 엔드포인트에 GitHub Copilot를 연결하도록 VS Code를 구성합니다.

  1. 생성 또는 업데이트 .vscode/mcp.json:

    {
        "servers": {
            "tasks-mcp-server": {
                "type": "sse",
                "url": "https://<your-app-fqdn>/mcp"
            }
        }
    }
    

    배포 출력에서 <your-app-fqdn>을 FQDN으로 교체하십시오.

    중요합니다

    "type"은(는) "sse"이어야 합니다. 이 자습서는 SSE 전송을 사용하기 때문입니다. (스트리밍 가능한 HTTP)를 사용하면 "http" 연결 오류가 발생합니다.

  2. VS Code에서 에이전트 모드에서 코필로트 채팅을 엽니다.

  3. tasks-mcp-server이(가) 도구 목록에 표시되는지 확인합니다. 필요한 경우 시작을 선택합니다.

  4. "어떤 작업이 있나요?"와 같은 프롬프트를 사용하여 테스트합니다.

대화형 사용을 위한 크기 조정 구성

Java 애플리케이션은 콜드 시작 시간이 더 깁니다. JVM을 따뜻하게 유지하도록 최소 복제본 수를 설정합니다.

az containerapp update \
    --name $APP_NAME \
    --resource-group $RESOURCE_GROUP \
    --min-replicas 1

팁 (조언)

JVM에 적합한 리소스 설정을 고려합니다. Spring Boot 애플리케이션에는 최소 1개의 vCPU 및 2GiB 메모리가 권장됩니다.

보안 고려 사항

이 자습서에서는 간단히 하기 위해 인증되지 않은 MCP 서버를 사용합니다. 프로덕션 환경에서 MCP 서버를 실행하기 전에 다음 권장 사항을 검토합니다. 대규모 언어 모델(LLM)으로 구동되는 에이전트에서 MCP 서버를 호출하는 경우 프롬프트 삽입 공격에 유의해야 합니다.

  • 인증 및 권한 부여: Microsoft Entra ID를 사용하여 MCP 서버를 보호합니다. Container Apps에서 보안 MCP 서버를 참조하세요.
  • 입력 유효성 검사: Bean 유효성 검사를 사용하여 도구 매개변수의 유효성을 검사합니다 (, @Valid, @NotNull). Spring Boot의 유효성 검사를 참조하세요.
  • HTTPS: Azure Container Apps는 기본적으로 자동 TLS 인증서를 사용하여 HTTPS를 적용합니다.
  • 최소 권한: 사용 사례에 필요한 도구만 노출합니다. 확인 없이 파괴적인 작업을 수행하는 도구를 사용하지 않습니다.
  • CORS: 프로덕션 환경에서 허용된 원본을 신뢰할 수 있는 도메인으로 제한합니다.
  • 로깅 및 모니터링: SLF4J 및 Azure Monitor를 사용하여 감사를 위한 MCP 도구 호출을 기록합니다.

자원을 정리하세요

이 애플리케이션을 계속 사용하지 않려면 리소스 그룹을 삭제하여 이 자습서에서 만든 모든 리소스를 제거합니다.

az group delete --resource-group $RESOURCE_GROUP --yes --no-wait

다음 단계: