Class 2: From a REST Application to an MCP Server
Duration: ~35 minutes | Level: Intermediate | Prerequisites: Class 1: Why Spring AI + MCP?, with the starter cloned and running. No API key and no model are needed for this class.
What We'll Cover
- Which MCP server starter to add, and why this application needs the WebMVC one
- The four properties worth setting, and what each one changes
- The first
@McpTool, and the schema Spring AI generates from it - Driving the server with
curl:initialize,tools/list,tools/call - What happens to the REST controller
Class 1 ended with the starter cloned and running. If you skipped it, clone the main branch and open the project in your IDE:
git clone https://github.com/the-mcp-guy/spring-ai-mcp-course.git
Choosing a Starter
Spring AI ships three server starters, and the right one depends on how the application already serves HTTP.
| Artifact | Use it when |
|---|---|
spring-ai-starter-mcp-server | The server speaks stdio only, with no HTTP at all |
spring-ai-starter-mcp-server-webmvc | The application is Spring MVC, on Tomcat |
spring-ai-starter-mcp-server-webflux | The application is reactive, on Netty |
order-service has a @RestController on Tomcat, so it takes the WebMVC one. Add it to order-service/pom.xml:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
The version comes from the Spring AI BOM, which the parent pom.xml already imports. Class 15 comes back to the WebFlux starter, once there is something running to compare it against.
Configuration
Add an mcp block to order-service/src/main/resources/application.yaml:
spring:
ai:
mcp:
server:
name: order-service
version: 1.0.0
protocol: STREAMABLE
Three of those are worth understanding before moving on.
name and version identify the server. They come back in the serverInfo of the handshake, and clients act on them: Class 10 writes a tool filter that matches on order-service, and a prefix generator that puts the server name in front of every tool name the model is given. The defaults are mcp-server and 1.0.0, so two unconfigured Spring AI servers report the same identity, and code like that has no way to tell them apart.
protocol selects the transport. STREAMABLE is Streamable HTTP, the transport that replaced HTTP+SSE. The other values are SSE, deprecated as of Spring AI 2.0.0, and STATELESS, which Class 15 covers.
STREAMABLE is already the default in Spring AI 2.0.0, so the application would publish /mcp without this line. It is set here on purpose. In Spring AI 1.1.x the default was SSE, so the same application built against the previous line published /sse and /mcp/message instead, and a POST to /mcp came back as 404 Not Found. Writing the value down means the endpoint does not move when the dependency is upgraded.
Two more defaults are worth knowing about, though neither needs setting:
spring.ai.mcp.server.typeisSYNC. Class 15 coversASYNC.spring.ai.mcp.server.annotation-scanner.enabledistrue, which is what finds the annotated methods below. Spring AI 1.0 needed aMethodToolCallbackProviderbean for this; 1.1 replaced it with the scanner.
Let the server tell us what it is doing
The starter keeps its log quiet. application.yaml ends with a logging block that sets the root level to WARN and lifts only our own package to INFO, so anything Spring's own packages log at INFO never reaches the terminal. Spring AI's MCP autoconfiguration is one of those packages, which means the server would start, find the tool we are about to write, and say nothing about it.
That suits an application in production and works against us while learning what the framework does on our behalf. Add a third entry to that block:
logging:
level:
root: WARN
com.themcpguy: INFO
org.springframework.ai.mcp: INFO
That prefix covers Spring AI's MCP support as a whole, the client side included, so the same line still earns its place in Class 6 when support-agent connects as a client. Leave it in for the rest of the course: most classes add something the server reports while it starts.
The First Tool
OrderService in the starter already knows how to find an order. The MCP layer does not reimplement any of that, it describes it. Create order-service/src/main/java/com/themcpguy/supportdesk/orders/mcp/OrderTools.java:
package com.themcpguy.supportdesk.orders.mcp;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;
import com.themcpguy.supportdesk.orders.domain.Order;
import com.themcpguy.supportdesk.orders.service.OrderService;
@Component
public class OrderTools {
private final OrderService orderService;
OrderTools(OrderService orderService) {
this.orderService = orderService;
}
@McpTool(
name = "get_order",
description = """
Look up a single order by its ID.
Returns the status, the customer, the line items, the total and the shipment.
Order IDs look like ORD-10001.
""",
annotations = @McpTool.McpAnnotations(
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
public Order getOrder(
@McpToolParam(description = "The order ID, for example ORD-10001") String orderId) {
return orderService.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException(
"No order with ID '%s'. Check the ID and try again.".formatted(orderId)));
}
}
The import to note is org.springframework.ai.mcp.annotation.McpTool. Spring AI 2.0 keeps the MCP annotations in one package for both the client and the server side, so there is no separate ...server.annotation package to look for.
Four things in that class describe the tool to the client:
name is what the model sees. The Java method is called getOrder; the tool is called get_order. Leaving name out would derive one from the method name, and setting it means renaming the method later does not rename the tool.
description is the only documentation a model gets. There is no signature to read and no source to look at. Class 3 goes into how to write these.
@McpToolParam describes one parameter, and its text ends up in the generated schema.
The annotations block carries behavioural hints. They tell a client what kind of operation this is: readOnlyHint = true says the call changes nothing, idempotentHint = true says calling it twice is the same as calling it once. A host can use these to decide what needs confirming. Class 12 uses the opposite case, a tool that does need confirming.
Nothing registers the class. @Component puts it in the context and the annotation scanner finds the method.
Start It
mvn -pl order-service spring-boot:run
With the logging line in place, the terminal says this. The timings and the PID will be your own:
c.t.s.orders.OrderServiceApplication : Starting OrderServiceApplication using Java 21 with PID …
c.t.s.orders.OrderServiceApplication : No active profile set, falling back to 1 default profile: "default"
trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerAnnotationScannerAutoConfiguration' … is not eligible for getting processed by all BeanPostProcessors
trationDelegate$BeanPostProcessorChecker : Bean 'serverAnnotatedBeanRegistry' … is not eligible for getting processed by all BeanPostProcessors
trationDelegate$BeanPostProcessorChecker : Bean 'spring.ai.mcp.server.annotation-scanner-…McpServerAnnotationScannerProperties' … is not eligible for getting processed by all BeanPostProcessors
o.s.a.m.s.c.a.McpServerAutoConfiguration : Enable tools capabilities, notification: true
o.s.a.m.s.c.a.McpServerAutoConfiguration : Registered tools: 1
o.s.a.m.s.c.a.McpServerAutoConfiguration : Enable resources capabilities, notification: true
o.s.a.m.s.c.a.McpServerAutoConfiguration : Enable resources templates capabilities, notification: true
o.s.a.m.s.c.a.McpServerAutoConfiguration : Enable prompts capabilities, notification: true
o.s.a.m.s.c.a.McpServerAutoConfiguration : Enable completions capabilities
c.t.s.orders.OrderServiceApplication : Started OrderServiceApplication in 1.227 seconds
c.t.s.orders.config.DataInitializer : Seeded 42 customers and 200 orders
A few of those lines are worth explaining.
The three BeanPostProcessorChecker warnings are Spring AI's annotation scanner being built early enough in the context that Spring cannot apply every bean post-processor to it. They appear on every start and nothing we write makes them go away. They also do not stop the tool being registered, which the tools/list call below confirms.
Registered tools: 1 is the annotation scanner reporting what it found. It says the method was discovered and nothing more: the line appears whether or not the endpoint is reachable.
The four capability lines under it are Spring AI advertising resources, prompts and completions we have not written yet. The initialize reply in the next section repeats the same claim, and Classes 4 and 5 fill them in.
No Tomcat started on port 8080 line, which many Spring Boot developers look for first. The web server logs that at INFO from its own package, and the root level is still WARN, so it stays hidden. The curl calls below are how we check the port is answering.
Drive It With curl
MCP is JSON-RPC over HTTP here, so curl is enough to exercise the whole protocol. We use it in every class that adds something to the server, because it shows what a client actually receives.
Open a session
Streamable HTTP needs an Accept header naming both content types, because the server chooses which one to use for the reply:
curl -i -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
HTTP/1.1 200
Mcp-Session-Id: 004c7d3a-47fe-4b5b-99b8-8dadec67f348
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"order-service","version":"1.0.0"}}}
Two things in that reply are worth reading.
serverInfo carries the name and version we configured. And capabilities lists prompts, resources and completions even though we have written none of them: Spring AI advertises those capabilities by default, and the corresponding lists are simply empty. Classes 4 and 5 fill them in.
Copy the Mcp-Session-Id. Every request below needs it, and the value is different on every session.
Acknowledge the handshake, which the protocol requires before anything else:
curl -i -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
HTTP/1.1 202
Content-Length: 0
A status line and nothing else, which is the whole reply. A notification carries no id, so there is no result to send back and nothing to correlate a reply with. Over HTTP that absence still has to be expressed somehow, and 202 Accepted is how the server says it took the message.
Look at the generated schema
curl -N -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
initialize replied with Content-Type: application/json and a plain JSON body. This one
replies with Content-Type: text/event-stream, so the JSON arrives wrapped in three lines:
id:004c7d3a-47fe-4b5b-99b8-8dadec67f348
event:message
data:{"jsonrpc":"2.0","id":2,"result":{"tools":[ ... ]}}
That is Streamable HTTP doing what its name says: the server picks the content type per request, and it uses the stream once there is something to stream.
Everything after data: is the actual response. Your terminal prints it on one line. We
format it below for readability.
The tool from the reply, formatted for reading:
{
"name": "get_order",
"title": "get_order",
"description": "Look up a single order by its ID.\nReturns the status, the customer, the line items, the total and the shipment.\nOrder IDs look like ORD-10001.\n",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "The order ID, for example ORD-10001"
}
},
"required": [ "orderId" ]
},
"annotations": {
"title": "",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
}
We did not write that schema anywhere. "type": "string" came from the Java parameter, "required" from the parameter not being optional, the property description from @McpToolParam, and the annotations from @McpTool.McpAnnotations.
The parameters are written down once, in the method signature, and the schema is derived from them. That matters because a schema written by hand is data the compiler cannot check: add a parameter to getOrder and the compiler finds every caller, but it has no way to know a schema somewhere else was meant to agree. Here there is nothing to update by hand, because the schema is rebuilt the next time the application starts.
Call the tool
curl -N -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_order","arguments":{"orderId":"ORD-10001"}}}'
The result comes back as text containing the JSON serialisation of the Order we returned:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [ { "type": "text", "text": "{\"orderId\":\"ORD-10001\",\"status\":\"SHIPPED\", ... }" } ],
"isError": false
}
}
Ask for an order that does not exist and the exception message we wrote comes back instead:
{
"result": {
"content": [ { "type": "text",
"text": "Error invoking method: getOrder\nNo order with ID 'ORD-99999'. Check the ID and try again." } ],
"isError": true
}
}
Two things there.
isError is true and the call still returned 200. That is deliberate in MCP: a failed tool call is a result the model is meant to read and react to, not a transport error. It is why the message says what to do next rather than just naming the problem.
And Spring AI prefixes the message with Error invoking method: getOrder. That is the Java method name, not the tool name, so a model reading it sees an internal detail we did not write. It is harmless, because the sentence after it carries the useful part, but it is worth knowing the model reads both. Class 3 comes back to what belongs in that sentence.
The REST Controller Is Untouched
curl http://localhost:8080/api/orders/ORD-10001
That still answers, exactly as it did in Class 1. The application now serves two audiences from the same OrderService: /api/orders/{id} for the applications that call it today, and /mcp for a model. Neither knows about the other, and OrderService was not changed to add MCP.
This is the usual situation. An application that already exists gains an MCP endpoint by describing what it can already do.
What We Built
order-service is an MCP server. It completes the handshake, reports one tool with a schema we did not write, executes that tool against the real database, and returns errors in the form a model can act on.
Class 3 adds the rest of the tools and looks at what makes a description good enough for a model to choose correctly.
Next: Class 3: Tools in Depth. The rest of the tools, output schemas, and writing descriptions a model can act on.