Compartir a través de


Tutorial: Implementación de un servidor MCP de Java en Azure Container Apps

En este tutorial, creará un servidor de Protocolo de contexto de modelo (MCP) que expone herramientas de administración de tareas mediante Spring Boot y el SDK de Java de MCP. Implemente el servidor en Azure Container Apps y conéctese a él desde GitHub Copilot Chat en VS Code.

En este tutorial, usted hará lo siguiente:

  • Creación de una aplicación de Spring Boot que expone herramientas de MCP
  • Probar el servidor MCP localmente con GitHub Copilot
  • Containerización y despliegue de la aplicación en Azure Container Apps
  • Conexión de GitHub Copilot al servidor MCP implementado

Prerrequisitos

Crea el esqueleto de la aplicación

En esta sección, creará un nuevo proyecto de Spring Boot con el SDK de Java de MCP.

  1. Cree el directorio del proyecto:

    mkdir tasks-mcp-server && cd tasks-mcp-server
    
  2. Crear 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 define una aplicación de Spring Boot con dos dependencias clave: spring-boot-starter-web para el marco web y mcp-spring-webmvc para el SDK de MCP. El complemento Maven de Spring Boot empaqueta la aplicación como un archivo JAR ejecutable.

    Nota:

    El SDK de Java de MCP está en desarrollo activo. Compruebe las versiones del SDK de Java de MCP para obtener la versión más reciente y actualice la <version> correspondiente.

  3. Cree la estructura de directorios:

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

    server.port=8080
    

Definir el modelo de datos y el almacén

En esta sección, definirá el modelo de datos de tareas y un almacén en memoria.

  1. Crear 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; }
    }
    

    La TaskItem clase define el modelo de datos con captadores estándar y un establecedor para el estado de finalización. El constructor inicializa la createdAt marca de tiempo automáticamente.

  2. Crear 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);
        }
    }
    

    El TaskStore componente Spring administra una lista en memoria rellenada previamente con datos de ejemplo. Utiliza AtomicInteger para la generación de ID seguras para subprocesos y proporciona métodos para operaciones CRUD estándar.

Definición de las herramientas de MCP

En esta sección, definirá las herramientas de MCP que el modelo de IA puede invocar y configurar el servidor MCP en la aplicación spring Boot.

  1. Crear 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);
        }
    }
    

    Nota:

    La superficie de la API del SDK de Java de MCP está evolucionando. El patrón de registro de herramientas que se muestra aquí usa SyncToolSpecification para herramientas sincrónicas. Consulte la documentación del SDK de Java de MCP para ver las expresiones y anotaciones más recientes que pueden simplificar el registro de herramientas.

  2. Crear 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;
        }
    }
    

    Puntos clave:

    • WebMvcSseServerTransportProvider registra el transporte SSE en la ruta /mcp.
    • McpServer.sync(transport) configura las funcionalidades de las herramientas y registra cada especificación de herramienta.
    • CORS está habilitado porque GitHub Copilot en VS Code realiza solicitudes de origen cruzado a los servidores MCP.

    Nota:

    En este tutorial se usa WebMvcSseServerTransportProvider (transporte SSE) porque el SDK de Java de MCP aún no ofrece un transporte HTTP estable que se puede transmitir. Los otros tutoriales de lenguaje (.NET, Python, Node.js) usan HTTP que se puede transmitir. Cuando el SDK de Java agregue compatibilidad con HTTP transmisible, actualice el proveedor de transporte en consecuencia. El transporte SSE es totalmente compatible con VS Code Copilot y otros clientes MCP.

  3. Crear 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";
        }
    }
    

    La clase principal inicia la aplicación Spring Boot y expone un punto de conexión /health para sondeos de estado de Container Apps. Los endpoints de MCP devuelven respuestas JSON-RPC, por lo que se necesita un endpoint de estado independiente para las comprobaciones de salud.

Probar el servidor MCP localmente

Antes de implementar en Azure, compruebe que el servidor MCP funciona ejecutandolo localmente y conectándose desde GitHub Copilot.

  1. Compilación y ejecución:

    mvn spring-boot:run
    
  2. Abra VS Code y, a continuación, abra Chat de Copilot y seleccione Modo de agente .

  3. Seleccione el botón Herramientas y, a continuación, Agregar más herramientas...>Agregue el servidor MCP.

  4. Seleccione HTTP (HTTP o Server-Sent Events) y elija Server-Sent Events (SSE) cuando se le solicite el tipo de transporte.

    Importante

    En este tutorial se usa el transporte SSE, no HTTP transmisible. Debe seleccionar la opción SSE en VS Code para que la conexión funcione correctamente.

  5. Escriba la dirección URL del servidor: http://localhost:8080/mcp

  6. Escriba un identificador de servidor: tasks-mcp

  7. Seleccione Configuración del área de trabajo.

  8. Prueba con: "Mostrarme todas las tareas"

  9. Seleccione Continuar cuando Copilot solicite confirmación de la herramienta MCP.

Debería ver la lista de tareas devuelta desde su almacén en memoria.

Sugerencia

Pruebe otras indicaciones como "Crear una tarea para revisar la PR", "Marcar la tarea 1 como completada" o "Eliminar la tarea 2".

Contenerizar la aplicación

Empaquete la aplicación como contenedor de Docker para que pueda probarla localmente antes de implementarla en Azure.

  1. Cree un Dockerfile con una construcción de varias fases:

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

    La compilación en varias fases mantiene el tamaño reducido de la imagen final al separar la compilación de Maven de la imagen en tiempo de ejecución.

  2. Compruebe localmente:

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

    Confirme que el punto de conexión de mantenimiento responde: curl http://localhost:8080/health

Implementación en Azure Container Apps

Después de incluir la aplicación en contenedores, impleméntela en Azure Container Apps mediante la CLI de Azure. El az containerapp up comando compila la imagen de contenedor en la nube, por lo que no necesita Docker en la máquina para este paso.

  1. Establecer variables de entorno:

    RESOURCE_GROUP="mcp-tutorial-rg"
    LOCATION="eastus"
    ENVIRONMENT_NAME="mcp-env"
    APP_NAME="tasks-mcp-server-java"
    
  2. Cree un grupo de recursos y un entorno de Container Apps:

    az group create --name $RESOURCE_GROUP --location $LOCATION
    
    az containerapp env create \
        --name $ENVIRONMENT_NAME \
        --resource-group $RESOURCE_GROUP \
        --location $LOCATION
    
  3. Implemente la aplicación contenedora:

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

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

    Nota:

    Para producción, reemplace los orígenes comodín por orígenes de confianza específicos. Consulte Protección de servidores MCP en Container Apps.

  5. Compruebe la implementación:

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

Conexión de GitHub Copilot al servidor implementado

Ahora que el servidor MCP se ejecuta en Azure, configure VS Code para conectar GitHub Copilot al punto de conexión implementado.

  1. Crear o actualizar .vscode/mcp.json:

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

    Reemplace <your-app-fqdn> por el FQDN de la salida de la implementación.

    Importante

    "type" debe ser "sse" porque en este tutorial se usa el transporte SSE. El uso de "http" (HTTP transmitible) provoca fallos de conexión.

  2. En VS Code, abra El chat de Copilot en modo agente.

  3. Compruebe que tasks-mcp-server aparece en la lista de herramientas. Seleccione Iniciar si es necesario.

  4. Pruebe con un mensaje como "¿Qué tareas tengo?"

Configuración del escalado para uso interactivo

Las aplicaciones java tienen tiempos de inicio en frío más largos. Establezca un número mínimo de réplicas para mantener la JVM en caliente:

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

Sugerencia

Considere la configuración de recursos adecuada para JVM. Se recomienda un mínimo de 1 vCPU y 2 GiB para aplicaciones de Spring Boot.

Consideraciones de seguridad

En este tutorial se usa un servidor MCP no autenticado para simplificar. Antes de ejecutar un servidor MCP en producción, revise las siguientes recomendaciones. Cuando su servidor MCP sea llamado por un agente impulsado por modelos de lenguaje grandes (LLM), tenga cuidado con los ataques de inyección de solicitudes.

  • Autenticación y autorización: proteja el servidor MCP con el identificador de Microsoft Entra. Consulte Protección de servidores MCP en Container Apps.
  • Validación de entrada: use Bean Validation (@Valid, @NotNull, @Size) para la validación de parámetros de la herramienta. Consulte Validación en Spring Boot.
  • HTTPS: Azure Container Apps aplica HTTPS de forma predeterminada con certificados TLS automáticos.
  • Privilegios mínimos: exponga solo las herramientas que requiere su caso de uso. Evite herramientas que realicen operaciones destructivas sin confirmación.
  • CORS: restringir orígenes permitidos a dominios de confianza en producción.
  • Registro y supervisión: Registrar las invocaciones de la herramienta MCP para la auditoría utilizando SLF4J y Azure Monitor.

Limpieza de recursos

Si no va a seguir usando esta aplicación, elimine el grupo de recursos para quitar todos los recursos creados en este tutorial:

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

Paso siguiente