Escribe tu primer servidor MCP con Java y Spring Boot
1. Visión general
Model Context Protocol (MCP) es un estándar de comunicación que permite a los asistentes de IA (Claude, Copilot, etc.) llamar herramientas, leer recursos de tu sistema de forma estructurada. Este artículo te guía para escribir un servidor MCP con Java 21, exponer una herramienta de búsqueda de pedidos (lookup_order), y conectarlo con un cliente MCP mediante transporte stdio. Al terminar, habrás construido un servidor MCP funcional, escrito tests para el manejador de herramientas, y conocerás los errores más comunes cuando empiezas.
2. ¿Qué es MCP y cuándo necesitas escribir un servidor?
MCP define tres conceptos principales que un servidor puede exponer:
- Tool: una función que el modelo de IA puede llamar, con un esquema de entrada claro (similar a function calling).
- Resource: datos estáticos o dinámicos que el cliente puede leer (archivos, configuración, registros de BD…).
- Prompt: plantillas de prompts preconfiguradas que el cliente puede reutilizar.
Necesitas escribir un servidor MCP cuando quieres que el asistente de IA manipule directamente tu sistema interno — por ejemplo, buscar pedidos, crear tickets, consultar logs — sin tener que copiar-pegar datos manualmente en el chat.
Nota: Si solo necesitas que el modelo responda basándose en texto estático, usar RAG o prompts comunes será mucho más simple que construir un servidor MCP completo.
3. Preparar el proyecto
3.1. Dependencias Maven
El SDK de Java para MCP proporciona clases para construir servidores y transportes. Añade la siguiente dependencia a tu pom.xml:
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.8.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
Jackson se usa para serializar/deserializar mensajes JSON-RPC que MCP utiliza internamente.
3.2. Modelo de dominio simple
Antes de escribir la herramienta, necesitas datos que la herramienta devuelva. Usa un Order simulado:
public record Order(String id, String status, double total) {
}
@Service
public class OrderService {
private final Map<String, Order> orders = Map.of(
"ORD-001", new Order("ORD-001", "SHIPPED", 450_000),
"ORD-002", new Order("ORD-002", "PENDING", 120_000)
);
public Optional<Order> findById(String orderId) {
return Optional.ofNullable(orders.get(orderId));
}
}
OrderService es solo un mapa en memoria para demostración — en un proyecto real, llamarías a un repositorio o API.
4. Define tu primera herramienta: lookup_order
Cada herramienta en MCP necesita un nombre, una descripción (que el modelo usa para decidir si debería llamar a la herramienta), y un esquema de entrada en formato JSON Schema.
public final class OrderTools {
public static final McpSchema.Tool LOOKUP_ORDER = new McpSchema.Tool(
"lookup_order",
"Busca el estado y el monto total de un pedido por su código orderId",
"""
{
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "Código del pedido, por ejemplo ORD-001"
}
},
"required": ["orderId"]
}
"""
);
}
"lookup_order"es el nombre de la herramienta, el cliente lo usará exactamente con esa cadena para llamarla.- La descripción en la segunda línea es crítica: el modelo se basa en esto para saber cuándo llamar esta herramienta en lugar de otra.
- El esquema requiere
orderIdcomo un campo obligatorio de tipostring.
Luego, escribe el manejador que ejecuta la herramienta — recibe parámetros, llama a OrderService, y devuelve un CallToolResult:
@Component
public class OrderToolHandler {
private final OrderService orderService;
public OrderToolHandler(OrderService orderService) {
this.orderService = orderService;
}
public McpSchema.CallToolResult handle(Map<String, Object> arguments) {
String orderId = (String) arguments.get("orderId");
return orderService.findById(orderId)
.map(order -> success(order))
.orElseGet(() -> error("Pedido no encontrado: " + orderId));
}
private McpSchema.CallToolResult success(Order order) {
String text = "Pedido %s: estado %s, total %.0f"
.formatted(order.id(), order.status(), order.total());
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(text)), false);
}
private McpSchema.CallToolResult error(String message) {
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(message)), true);
}
}
El segundo parámetro de CallToolResult es isError. Cuando es true, el cliente sabe que la herramienta falló y puede reportarlo al modelo para que intente otra cosa, en lugar de tratarlo como un resultado válido.
5. Inicializar el servidor MCP con transporte stdio
El transporte más común cuando empiezas a escribir servidores MCP es stdio: el servidor lee solicitudes de stdin, escribe respuestas a stdout. También es cómo Claude Desktop inicia servidores locales.
@Component
public class McpOrderServer implements CommandLineRunner {
private final OrderToolHandler toolHandler;
public McpOrderServer(OrderToolHandler toolHandler) {
this.toolHandler = toolHandler;
}
@Override
public void run(String... args) {
McpServerTransportProvider transportProvider =
new StdioServerTransportProvider(new ObjectMapper());
McpServerFeatures.SyncToolSpecification lookupOrderSpec =
new McpServerFeatures.SyncToolSpecification(
OrderTools.LOOKUP_ORDER,
(exchange, arguments) -> toolHandler.handle(arguments)
);
McpSyncServer server = McpServer.sync(transportProvider)
.serverInfo("nonla-order-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder()
.tools(true)
.build())
.tools(lookupOrderSpec)
.build();
server.start();
}
}
StdioServerTransportProviderconviertestdin/stdoutdel proceso en un canal de comunicación JSON-RPC.serverInfo(...)declara el nombre y versión — el cliente mostrará esta información cuando liste los servidores conectados.capabilities().tools(true)informa al cliente que este servidor expone herramientas, el cliente enviarátools/listpara obtener la lista.server.start()bloquea el proceso, esperando solicitudes del cliente a través de stdin.
Nota: Dado que el transporte stdio mantiene el proceso vivo bloqueando, si ejecutas esto en una aplicación Spring Boot web normal (con
@SpringBootApplicationcon servidor web), deberías desactivar el servidor web (spring.main.web-application-type=noneenapplication.properties) para evitar conflictos de puerto y entremezclamiento de logs en stdout.
spring.main.web-application-type=none
spring.main.banner-mode=off
logging.pattern.console=
El banner y los logs de consola deben desactivarse completamente — MCP usa stdout como canal de transmisión de datos, cualquier línea de log que se entremezle lo romperá.
6. Añade un recurso: expone datos estáticos
Además de herramientas, puedes exponer recursos que el cliente pueda leer directamente (sin necesidad de llamar a través del modelo):
McpSchema.Resource policyResource = new McpSchema.Resource(
"file:///policies/return-policy.md",
"Política de devolución",
"Contenido de la política de devolución de productos",
"text/markdown",
null
);
McpServerFeatures.SyncResourceSpecification policySpec =
new McpServerFeatures.SyncResourceSpecification(
policyResource,
(exchange, request) -> new McpSchema.ReadResourceResult(
List.of(new McpSchema.TextResourceContents(
policyResource.uri(),
"text/markdown",
"# Política de devolución\nLos clientes pueden devolver productos dentro de 7 días."
))
)
);
Añade .resources(policySpec) al builder en el paso 5 y el servidor ya expone este recurso adicional.
7. Pruébalo con Claude Desktop
Compila el jar y configura Claude Desktop (u otro cliente MCP) para apuntar a tu servidor:
{
"mcpServers": {
"nonla-order-server": {
"command": "java",
"args": ["-jar", "/path/to/order-mcp-server.jar"]
}
}
}
Reinicia el cliente, abre una nueva conversación y pregunta: “¿En qué estado está el pedido ORD-001?”. El modelo decidirá automáticamente llamar a lookup_order con orderId = "ORD-001", el servidor devuelve el texto, el modelo usa el resultado para responderte.
8. Escribe tests para el manejador de herramientas
La parte más importante de la lógica — procesar parámetros y devolver CallToolResult — es completamente testeable como código Java normal, sin necesidad de un transporte real.
class OrderToolHandlerTest {
private final OrderService orderService = new OrderService();
private final OrderToolHandler handler = new OrderToolHandler(orderService);
@Test
void shouldReturnOrderInfo_whenOrderExists() {
var result = handler.handle(Map.of("orderId", "ORD-001"));
assertThat(result.isError()).isFalse();
assertThat(result.content())
.hasSize(1)
.first()
.isInstanceOfSatisfying(McpSchema.TextContent.class,
content -> assertThat(content.text()).contains("SHIPPED"));
}
@Test
void shouldReturnError_whenOrderNotFound() {
var result = handler.handle(Map.of("orderId", "ORD-999"));
assertThat(result.isError()).isTrue();
}
}
El primer test verifica un pedido válido: isError() debe ser false y el contenido del texto debe contener el estado SHIPPED. El segundo test asegura que cuando no se encuentra un pedido, isError() devuelve true — es una señal importante para que el cliente/modelo sepa que necesita otro enfoque.
✓ shouldReturnOrderInfo_whenOrderExists
✓ shouldReturnError_whenOrderNotFound
9. Errores comunes
- Logs entremezclados en stdout: si usas transporte stdio pero olvidas desactivar banner/logs de consola, el cliente recibirá mensajes JSON-RPC rotos, errores de análisis muy difíciles de depurar. Siempre verifica que stdout contenga solo mensajes MCP redirigiendo a un archivo al probar manualmente.
- Descripción de herramienta demasiado vaga: si la descripción solo dice “lookup order”, el modelo a menudo no sabrá cuándo llamarla. Escribe descripciones claras que expliquen entrada/salida esperada.
- Esquema de entrada sin
required: el modelo puede llamar a la herramienta sin campos obligatorios, el manejador obtieneNullPointerExceptional hacer cast de parámetros. Siempre valida los parámetros de entrada en el manejador, no confíes completamente en el esquema. - Usar servidor async (
McpServer.async) pero no esperar correctamente: es fácil tener condiciones de carrera cuando muchas solicitudes de herramientas llegan casi simultáneamente. Si aún no estás familiarizado con reactive, empieza conMcpServer.synccomo en este artículo.
Cuándo NO escribir un servidor MCP separado: si tu sistema ya tiene una API REST sólida y solo necesitas que un cliente la llame directamente, añadir otra capa MCP es redundante. MCP solo vale la pena invertir cuando quieres que múltiples clientes de IA diferentes (Claude Desktop, plugin IDE, agente personalizado) puedan usar las mismas herramientas sin tener que integrar cada uno por separado.
10. Conclusión
Acabas de construir un servidor MCP Java completo: declarar herramientas con esquemas claros, escribir manejadores que procesen lógica, exponer recursos adicionales, y testear manejadores independientemente del transporte. El siguiente paso natural es explorar transporte SSE para ejecutar el servidor a través de HTTP en lugar de stdio, o añadir plantillas de prompts para tareas repetidas que sean comunes en tu dominio.