Class 2: From a REST Application to an MCP Server
Duration: ~35 minutes | Level: Intermediate | Prerequisites: Class 1: Why Spring AI + MCP?, with the course project cloned and running. No API key or model is needed for this class.
What We'll Cover
- Which MCP server starter to add, and why this application needs the WebMVC one
- The three properties worth setting, and the two defaults worth knowing about
- The first
@McpTooland 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 course project cloned and running. If you skipped it, clone the main branch and open it 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, without any HTTP |
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, the non-blocking server that WebFlux runs on |
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 dependency does not need a <version> tag, because the parent pom.xml imports the Spring AI BOM, a bill of materials: a pom that pins one consistent set of versions for a family of artifacts. Class 15 returns to the WebFlux starter once there is something running to compare 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
name and version identify the server. They come back in the serverInfo of the handshake, and clients act on them. In Class 10, where one agent is connected to three servers at once, a tool filter recognises the server that calls itself order-service and lets all of its tools through, while the other servers' tools go through an allow-list. The defaults are mcp-server and 1.0.0, so two unconfigured servers report the same identity and a filter like that cannot 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 written down because that default moved between releases:
| Spring AI | Default protocol | Endpoints published |
|---|---|---|
| 1.1.x | SSE | /sse and /mcp/message |
| 2.0.0 | STREAMABLE | /mcp |
Built against the 1.1.x line, the same application answered a POST to /mcp with 404 Not Found. Writing the value down means the endpoint does not move when the dependency is upgraded.
Two more defaults matter, though neither needs setting:
spring.ai.mcp.server.typeisSYNC. Class 15 coversASYNC.spring.ai.mcp.server.annotation-scanner.enabledistrue. The annotation scanner walks the beans in the application context, finds the MCP annotations on them and registers what it finds, which is how the method below becomes a tool. Spring AI 1.0 needed aMethodToolCallbackProviderbean for that job, and 1.1 added the scanner.ToolCallbackProviderbeans are still converted too, throughspring.ai.mcp.server.tool-callback-converter, which also defaults totrue.
Let the server tell us what it is doing
The course project 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. Spring AI's MCP autoconfiguration logs at INFO from its own package, so the server would start and register our tool without saying so.
In production the quiet log is what we want. While learning it hides what the framework does, so add a third entry:
logging:
level:
root: WARN
com.themcpguy: INFO
org.springframework.ai.mcp: INFO
That prefix covers Spring AI's MCP support as a whole, including the client side, and a per-package level always wins over the root level. Leave it in: most classes from here on report something at startup.
The First Tool
OrderService 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 client and server, so a separate ...server.annotation package does not exist.
Every part of that class becomes a field of the tool definition a client receives:
In OrderTools.java | In the tools/list reply |
|---|---|
@McpTool(name = "get_order") | "name" |
@McpTool(description = ...) | "description" |
the String orderId parameter | inputSchema.properties.orderId.type |
@McpToolParam(description = ...) | inputSchema.properties.orderId.description |
@McpToolParam and its default required = true | "required": [ "orderId" ] |
@McpTool.McpAnnotations(...) | "annotations" |
| nothing written | "title", which falls back to the tool name |
Four of them are worth explaining:
nameis what the model sees. The Java method is calledgetOrder, the toolget_order. Leavingnameout derives one from the method name; setting it means renaming the method later does not rename the tool.descriptionis the only documentation a model gets. The signature and the source stay on the server. Class 3 goes into how to write these.@McpToolParamdescribes one parameter. Itsrequiredattribute defaults totrue, which is what putsorderIdin the schema'srequiredlist. Setrequired = falseto make an argument optional.- The
annotationsblock carries behavioural hints.readOnlyHint = truesays the call does not change anything.destructiveHintsays whether a change can delete or overwrite, andidempotentHint = truesays that calling the tool twice has the same effect as calling it once. The specification makes both meaningful only whenreadOnlyHintisfalse, so here they do not tell a client anything. They start to matter in Class 12, oncancel_order, which setsreadOnlyHint = falseand asks a person before it changes an order.
A host may use the hints to decide what to confirm. The specification tells clients to treat what a server says about its tools as untrusted unless the server itself is trusted, so hints guide a user interface and cannot replace an authorisation check.
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. Your timings and PID will differ:
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
Four of those lines are worth reading:
| Line | What it tells us |
|---|---|
the three BeanPostProcessorChecker warnings | Spring AI's annotation scanner is built early enough in the context that Spring cannot apply every bean post-processor to it. They appear on every start, and the tool is still registered, which the tools/list call below confirms. |
Registered tools: 1 | The annotation scanner found the annotated method. It does not say that the endpoint is reachable. |
| the four capability lines under it | Spring AI advertises resources, resource templates, prompts and completions that we have not written yet. The initialize reply in the next section repeats the same claim, and Classes 4 and 5 fill it in. |
the missing Tomcat started on port 8080 line | Tomcat logs at INFO from its own package, and the root level is still WARN, so the line stays hidden. The curl calls below are how we check that the port is answering. |
A bean post-processor is a Spring hook that sees every bean after it is created, and it is how features such as proxying get applied. Nothing we write removes those warnings.
Drive It With curl
MCP is JSON-RPC over HTTP here, so curl is enough to exercise the whole protocol and show what a client receives. This section makes four calls:
Only initialize answers in plain JSON. Its reply carries the Mcp-Session-Id that every later request repeats.
On Windows: the same requests in PowerShell (click to expand)
Three changes turn each command here into a PowerShell one. Write curl.exe rather than curl, because Windows PowerShell treats curl as an alias for Invoke-WebRequest, which does not take these flags. Line continuations are backticks instead of backslashes. And the single-quoted JSON body after -d cannot be passed inline. PowerShell 7.3 and later hands it to curl.exe intact. Windows PowerShell 5.1, the version built into Windows, rebuilds the command line, so the inner double quotes do not survive and the server receives invalid JSON. Saving the body to a file and passing it by name works in both versions.
The initialize call with all three changes applied:
@'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}
'@ | Set-Content -Path body.json
curl.exe -i -X POST http://localhost:8080/mcp `
-H "Content-Type: application/json" `
-H "Accept: application/json, text/event-stream" `
-d "@body.json"
-i prints the response headers, so the Mcp-Session-Id appears and you copy it by eye. The remaining commands transform the same way: curl.exe, backtick continuations, the body in a file, and every -H header kept as written.
Open a session
Streamable HTTP needs an Accept header naming both content types, because the server chooses which one to use for the reply. Leave either one out and the server answers 400 Bad Request: Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON.
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"}}}
serverInfo carries the name and version we configured. capabilities lists logging, prompts, resources and completions even though we have not written any of them: Spring AI advertises them by default and the lists come back empty. In Classes 4 and 5 we add the resources and the prompts, and in Class 11 we use the logging capability to send messages to the client while a tool is still running.
Copy the Mcp-Session-Id. Every request below needs it, and it differs in each session.
Acknowledge the handshake, which the protocol requires, before anything else. From here on, each request also carries an MCP-Protocol-Version header, required on every request after initialize. If it is missing, and the server cannot tell from anywhere else which version was negotiated, the server assumes 2025-03-26. If the version is one the server does not support, it answers 400 Bad Request.
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' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
HTTP/1.1 202
Content-Length: 0
The reply is only the status line: a notification does not carry an id, and a JSON-RPC result can only be sent back against an id. 202 Accepted is how the server says over HTTP that 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' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-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":[ ... ]}}
The specification lets a server answer either way, and a client has to handle both. Spring AI opens a stream for every request after initialize that expects a reply, which is why a single tool definition arrives inside an event frame. Everything after data: is the response, and your terminal prints it on one line.
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. Every field in it came from the Java class, through the mapping in the table above.
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 cannot know that a schema elsewhere was meant to agree. Here the only thing to update is the Java method: 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' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_order","arguments":{"orderId":"ORD-10001"}}}'
Spring AI turns the Order into JSON text, and that is what comes back:
{
"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:
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"content": [ { "type": "text",
"text": "Error invoking method: getOrder\nNo order with ID 'ORD-99999'. Check the ID and try again." } ],
"isError": true
}
}
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, which is why the message says what to do next. A model can act on "Check the ID and try again". MCP keeps it apart from a protocol error:
| Kind of failure | Where it appears | What the model can do with it |
|---|---|---|
| tool execution error | inside result, with "isError": true and the text above | read the message and try again with a different argument |
| protocol error | a JSON-RPC error object in place of result. Asking for a tool the server does not have gives the code -32602 with the fixed message Unknown tool: invalid_tool_name, and the name that was asked for in the error's data field | little, because the tool it asked for does not exist |
Spring AI prefixes the message with Error invoking method: getOrder, the Java method name, so a model reading it sees an internal detail we did not write. That happens to every failure: when a tool method throws an exception, Spring AI puts its message and its root cause's message into the text the model reads. An exception carrying a SQL fragment or a connection string would reach the model the same way, so a tool should throw exceptions whose messages we chose. 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.
OrderService does not know which path called it, and it was not changed to add MCP. An existing application gains an MCP endpoint by describing what it can already do.
Securing the new endpoint
The starter publishes /mcp on the same port as the REST API, and it does not authenticate anyone. That is safe here, because the server only answers on your own machine. On a real service it needs the same security boundary as every other endpoint. The Spring AI documentation has a section on securing the MCP server, and Spring AI MCP Security covers OAuth 2.0 and API keys. That page marks itself as community-driven and still in progress.
The transport specification also says a server MUST validate the Origin header, so that a page in your browser cannot POST to http://localhost:8080/mcp and call get_order. Spring AI installs an empty validator by default, so adding one is our job.
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 a form a model can act on.
Next: Class 3: Tools in Depth. The rest of the tools, output schemas, and writing descriptions that a model can act on.
Further Reading
- MCP specification: Transports: the whole Streamable HTTP contract, including the GET stream and session termination, which this class leaves for later.
- MCP specification: Lifecycle: why
initializecomes first, what each side may send beforenotifications/initializedarrives, and how the protocol version is negotiated. - MCP specification: Tools: the rest of the
tools/listandtools/callcontract, including output schemas and structured content, which Class 3 builds on. - MCP specification: Security Best Practices: what an HTTP MCP endpoint has to defend against once it leaves localhost, including hijacking of the
Mcp-Session-Idthis class copies by hand. - MCP Server Boot Starter, Spring AI Reference: why the HTTP endpoint is unauthenticated by default, and what has to go in front of it before the server leaves localhost.
- Streamable-HTTP MCP Servers, Spring AI Reference: the property table for this transport, and the server features this class leaves for later: keep-alive, ping, progress and logging.
- MCP Server Annotations, Spring AI Reference: every attribute of
@McpTooland@McpToolParamwith its default, includingtitle,generateOutputSchemaandrequired. - Spring AI MCP Security: the OAuth 2.0 authorization server setup and the API-key repository, with working examples of both.
- JSON-RPC 2.0 Specification: the request, response and notification shapes MCP is built on, including why a message without an
iddoes not get a reply. - Log Levels, Spring Boot Reference: how
logging.level.<package>entries combine with the root level, which is what makes theorg.springframework.ai.mcpline work.
Sources
- MCP Server Boot Starter, Spring AI Reference: the three server starters, one per transport family, and
SSEbeing deprecated as of Spring AI 2.0.0. - Streamable-HTTP MCP Servers, Spring AI Reference: the defaults
mcp-server,1.0.0,SYNC,annotation-scanner.enabledtrue,tool-callback-convertertrue, and all capabilities enabled. - McpServerProperties.java at v2.0.0:
protocoldefaulting toSTREAMABLEin 2.0.0, and the enum holdingSSE,STREAMABLEandSTATELESS. - McpTool.java at v2.0.0: the package
org.springframework.ai.mcp.annotationand the nested@McpTool.McpAnnotations. - modelcontextprotocol schema.ts, revision 2025-11-25:
destructiveHintandidempotentHintbeing meaningful only whenreadOnlyHintis false. - WebMvcStreamableServerTransportProvider.java at v2.0.0:
initializeanswering with JSON plusMcp-Session-Id, notifications answering202, every later request opening a stream, the400and itsInvalid Accept headersmessage, and the emptyOriginvalidator. - AbstractSyncMcpToolMethodCallback.java at v2.0.0: the exact error text, the wrapper message then the root cause's message, with
isErrortrue. - McpAsyncServer.java at v2.0.0, MCP Java SDK: the
-32602protocol error, and its exact message, for atools/callnaming a tool the server does not have. - McpServerAutoConfiguration.java at v2.0.0: the startup log lines quoted above, in the order they appear.
- PostProcessorRegistrationDelegate.java, Spring Framework: the
BeanPostProcessorCheckerlines being logged atWARN, which is why they survive theroot: WARNlevel. - MCP specification: Transports: the
Acceptheader naming both media types,202with an empty body for a notification, theMCP-Protocol-Versionfallback to2025-03-26, andOriginvalidation. - MCP specification: Tools:
isErrorresults being tool execution errors the model reads, JSON Schema 2020-12 as the default forinputSchema, and clients treating tool annotations as untrusted. - What's New in PowerShell 7.3: argument passing to native executables changing in 7.3, which is why 7.3 and later hand the JSON body to
curl.exeintact. - spring-ai-starter-mcp-server-webmvc 2.0.0 on Maven Central: the artifact ID added to the pom, published at 2.0.0.