Skip to main content

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 @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 course project cloned and running. If you skipped it, clone the main branch and open it in your IDE:

Clone the project
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.

ArtifactUse it when
spring-ai-starter-mcp-serverThe server speaks stdio only, without any HTTP
spring-ai-starter-mcp-server-webmvcThe application is Spring MVC on Tomcat
spring-ai-starter-mcp-server-webfluxThe 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 AIDefault protocolEndpoints published
1.1.xSSE/sse and /mcp/message
2.0.0STREAMABLE/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.type is SYNC. Class 15 covers ASYNC.
  • spring.ai.mcp.server.annotation-scanner.enabled is true. 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 a MethodToolCallbackProvider bean for that job, and 1.1 added the scanner. ToolCallbackProvider beans are still converted too, through spring.ai.mcp.server.tool-callback-converter, which also defaults to true.

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:

order-service/src/main/resources/application.yaml
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.javaIn the tools/list reply
@McpTool(name = "get_order")"name"
@McpTool(description = ...)"description"
the String orderId parameterinputSchema.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:

  • name is what the model sees. The Java method is called getOrder, the tool get_order. Leaving name out derives one from the method name; setting it means renaming the method later does not rename the tool.
  • description is the only documentation a model gets. The signature and the source stay on the server. Class 3 goes into how to write these.
  • @McpToolParam describes one parameter. Its required attribute defaults to true, which is what puts orderId in the schema's required list. Set required = false to make an argument optional.
  • The annotations block carries behavioural hints. readOnlyHint = true says the call does not change anything. destructiveHint says whether a change can delete or overwrite, and idempotentHint = true says that calling the tool twice has the same effect as calling it once. The specification makes both meaningful only when readOnlyHint is false, so here they do not tell a client anything. They start to matter in Class 12, on cancel_order, which sets readOnlyHint = false and 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

You run this
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:

LineWhat it tells us
the three BeanPostProcessorChecker warningsSpring 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: 1The annotation scanner found the annotated method. It does not say that the endpoint is reachable.
the four capability lines under itSpring 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 lineTomcat 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:

You run this in PowerShell
@'
{"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.

You run this
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.

You run this (with your own Mcp-Session-Id)
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

You run this (with your own Mcp-Session-Id)
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":{}}'
What comes back is an event stream

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

You run this (with your own Mcp-Session-Id)
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 failureWhere it appearsWhat the model can do with it
tool execution errorinside result, with "isError": true and the text aboveread the message and try again with a different argument
protocol errora 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 fieldlittle, 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

You run this
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 /mcp endpoint is open

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

Sources