教學:部署 Java MCP 伺服器到 Azure 容器應用

在這個教學中,你將建立一個模型情境協定(MCP)伺服器,透過使用 Spring Boot 和 MCP Java SDK 來暴露任務管理工具。 你要把伺服器部署到 Azure 容器應用程式,然後用 VS Code 從 GitHub Copilot Chat 連接。

在本教學課程中,您會:

  • 建立一個 Spring Boot 應用程式,揭露 MCP 工具
  • 用 GitHub Copilot 在本地測試 MCP 伺服器
  • 將應用程式容器化並部署至 Azure 容器應用程式
  • 將 GitHub Copilot 連接到已部署的 MCP 伺服器

先決條件

建立應用程式架構

在這個部分,你會用 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 應用程式:spring-boot-starter-web 用於網頁框架,mcp-spring-webmvc 用於 MCP SDK。 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 和一個完成狀態的設定器。 建構子會自動初始化 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 元件管理一個預載有範例資料的記憶體清單。 AtomicInteger 用於生成執行緒安全的識別碼,並提供標準 CRUD 操作的方法。

定義 MCP 工具

在本節中,你定義 AI 模型可調用的 MCP 工具,並在你的 Spring Boot 應用程式中配置 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 註冊 SSE 傳輸於路徑 /mcp
    • McpServer.sync(transport) 配置工具功能並登錄每個工具規格。
    • CORS 之所以啟用,是因為 GitHub Copilot 在 VS Code 中會對 MCP 伺服器發送跨來源請求。

    備註

    這個教學使用 WebMvcSseServerTransportProvider (SSE 傳輸)是因為 MCP Java SDK 尚未提供穩定的可串流 HTTP 傳輸。 其他語言教學(.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 應用程式,並提供容器應用程式健全狀態探查的/health端點。 MCP 端點會回傳 JSON-RPC 回應,因此健康檢查需要獨立的健康端點。

在本機測試 MCP 伺服器

在部署到 Azure 之前,先透過本地運行 MCP 伺服器並從 GitHub Copilot 連接來確認它正常運作。

  1. 建造與執行:

    mvn spring-boot:run
    
  2. 打開 VS Code,然後打開 Copilot 聊天 ,選擇 Agent 模式。

  3. 選擇 工具 按鈕,然後 新增更多工具......>新增 MCP 伺服器

  4. 選擇 HTTP(HTTP 或 Server-Sent 事件), 並在提示傳輸類型時選擇 Server-Sent 事件(SSE )。

    這很重要

    這個教學使用 SSE 傳輸,而非可串流的 HTTP。 你必須在 VS Code 中選擇 SSE 選項,連線才能正常運作。

  5. 輸入伺服器網址: http://localhost:8080/mcp

  6. 輸入伺服器 ID: tasks-mcp

  7. 選取 [工作區設定]。

  8. 使用下列項目測試:「顯示所有工作」

  9. 當 Copilot 要求 MCP 工具確認時,選擇 繼續

您應該會看到從記憶體內存放區傳回的工作清單。

小提示

試試其他提示,例如「建立任務以檢視 PR」、「標記任務 1 為完成」或「刪除任務 2」。

將應用程式容器化

把應用程式打包成 Docker 容器,這樣你可以在本地測試再部署到 Azure。

  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 容器應用程式

在你將應用程式容器化後,使用 Azure CLI 部署到 Azure 容器應用程式。 這個 az containerapp up 指令會在雲端建立容器映像,所以你不需要在機器上安裝 Docker。

  1. 設定環境變數:

    RESOURCE_GROUP="mcp-tutorial-rg"
    LOCATION="eastus"
    ENVIRONMENT_NAME="mcp-env"
    APP_NAME="tasks-mcp-server-java"
    
  2. 建立資源群組與容器應用程式環境:

    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 "*"
    

    備註

    在實際執行環境中,將萬用字元來源替換成特定的可信來源。 請參閱 容器應用程式上的 Secure 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 運行,設定 VS Code 將 GitHub Copilot 連接到已部署的端點。

  1. 建立或更新 .vscode/mcp.json

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

    用部署輸出的 FQDN 替換 <your-app-fqdn>

    這很重要

    "type"一定是"sse"因為這個教學使用 SSE 傳輸。 使用 "http" (可串流 HTTP)會導致連線失敗。

  2. 在 VS Code 中,以客服模式開啟 Copilot 聊天。

  3. 驗證 tasks-mcp-server 會出現在工具清單中。 如果需要,選擇 開始

  4. 用「我有哪些任務?」來測試。

為互動使用設定縮放

Java 應用程式的冷啟動時間較長。 設定最小副本數量以保持 JVM 溫度:

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

小提示

考慮適合 JVM 的資源設定。 春季開機應用程式建議至少配備 1 顆 vCPU 和 2 GiB 記憶體。

安全性考慮

本教學使用未認證的 MCP 伺服器以簡化操作。 在正式環境中運行 MCP 伺服器前,請先檢視以下建議。 如果呼叫 MCP 伺服器的是大型語言模型 (LLMs) 支援的代理程式,請留意提示插入類型的攻擊。

  • 認證與授權:用 Microsoft Entra ID 保護你的 MCP 伺服器。 請參閱 容器應用程式上的 Secure MCP 伺服器
  • 輸入驗證:使用 Bean Validation (@Valid@NotNull@Size) 來驗證工具參數。 請參見 Spring Boot 中的驗證
  • HTTPS:Azure 容器應用程式預設會強制執行 HTTPS,並自動使用 TLS 憑證。
  • 最低權限:只公開你使用情境所需的工具。 避免使用未經確認就進行破壞性操作的工具。
  • CORS:在執行環境中只允許受信任的網域作為來源。
  • 日誌與監控:使用 SLF4J 和 Azure 監視器 進行審計時,記錄 MCP 工具的調用。

清理資源

如果你不打算繼續使用這個應用程式,請刪除資源群組,以移除本教學中建立的所有資源:

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

下一個步驟