你当前正在访问 Microsoft Azure Global Edition 技术文档网站。 如果需要访问由世纪互联运营的 Microsoft Azure 中国技术文档网站,请访问 https://docs.azure.cn

教程:将 Java MCP 服务器部署到 Azure 容器应用

在本教程中,你将生成一个模型上下文协议(MCP)服务器,该服务器使用 Spring Boot 和 MCP Java SDK 公开任务管理工具。 将服务器部署到 Azure 容器应用,并从 VS Code 中的 GitHub Copilot 聊天连接到它。

在本教程中,你将了解:

  • 创建公开 MCP 工具的 Spring Boot 应用
  • 使用 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 用于 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 和一个 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 组件管理一个内存中已预填充示例数据的列表。 它使用 AtomicInteger 实现线程安全的 ID 生成,并提供标准 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) 配置工具功能并注册每个工具规范。
    • 已启用 CORS,因为 VS Code 中的 GitHub Copilot 向 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 对话助手 并选择 代理 模式。

  3. 选择 “工具 ”按钮,然后 添加更多工具...>添加 MCP 服务器

  4. 选择 HTTP(HTTP 或 Server-Sent Events),并在提示选择传输类型时选择 Server-Sent Events(SSE)

    重要

    本教程使用 SSE 传输,而不是可流式传输的 HTTP。 必须在 VS Code 中选择 SSE 选项才能使连接正常工作。

  5. 输入服务器 URL: 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 "*"
    

    注释

    在生产环境中,请将通配符源替换为具体的受信任源。 请参阅 容器应用中的安全 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"
            }
        }
    }
    

    <your-app-fqdn> 替换为部署输出中的 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 的资源设置。 对于 Spring Boot 应用程序,建议至少使用 1 个 vCPU 和 2 GiB 内存。

安全注意事项

本教程为简单起见,使用未经身份验证的 MCP 服务器。 在生产环境中运行 MCP 服务器之前,请查看以下建议。 当 MCP 服务器由由大型语言模型(LLM)提供支持的代理调用时,请注意 提示注入 攻击。

  • 身份验证和授权:使用 Microsoft Entra ID 保护 MCP 服务器。 请参阅 容器应用中的安全 MCP 服务器
  • 输入验证:使用 Bean 验证 (@Valid@NotNull@Size) 进行工具参数验证。 请参阅 Spring Boot 中的验证
  • HTTPS:Azure 容器应用默认使用自动 TLS 证书强制实施 HTTPS。
  • 最低特权:仅公开用例所需的工具。 避免使用在未经确认的情况下执行破坏性操作的工具。
  • CORS:将允许的源限制为生产中的受信任域。
  • 日志记录和监控:使用 SLF4J 和 Azure Monitor 记录 MCP 工具调用以进行审核。

清理资源

如果不打算继续使用此应用程序,请删除资源组以删除本教程中创建的所有资源:

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

后续步骤