Class 4: Resources
Duration: ~45 minutes | Level: Intermediate | Prerequisites: Class 3: Tools in Depth. Still works without an API key or a model.
What We'll Cover
- What a resource is, and who decides when it is read
@McpResourcewith a fixed URI and with a URI template- What a resource method may return
resources/list,resources/templates/listandresources/readovercurlMcpMeta, for the metadata that arrives with a request
This class carries on from Class 3. If you followed along, keep working in the project you
already have. If you skipped it, clone the class_3
branch to start from the same place:
git clone --branch class_3 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
Tools and Resources Answer Different Questions
In Class 3 we gave order-service four tools. A model decides which of them to call and when, from the tool descriptions and the conversation.
A resource works the other way round. It is identified by a URI, and something outside the model decides to attach it: in Claude Desktop, the person clicking a file. Claude Desktop is the host, the application the person is using, which owns the conversation and decides what the model sees. In the agent we build in Class 8, the host is our own code. Nothing in MCP gives the model a way to request a resource, though a host may let the model pick from the list.
Tools, resources and prompts are the three primitives an MCP server can expose, and the specification separates them by who decides:
| Primitive | Who decides it is needed | Where in this course |
|---|---|---|
| Tool | the model | Class 3 |
| Resource | the application | this class |
| Prompt | the person | Class 5 |
The specification calls resources application-driven, and the difference is when the data arrives. A resource is read before the conversation starts:
order-service is called first, before the person has typed anything, so the policy is already in the request the model reads.
A tool is called partway through the same answer:
Here order-service is called only after the model has worked out that it needs an order.
"Attach" is worth taking literally. In Class 8 the resource text goes into the system prompt, the block of instructions the application puts in front of the conversation on every request. The provider's API does not know what an MCP resource is, so the model reads the policy as part of its instructions, for as long as the application keeps sending it.
Two things in order-service fit that shape:
| What | Why it belongs in the request from the start |
|---|---|
| the policy documents | an answer about returns has to come from our policy, not from what the model remembers about returns policies in general, and attaching it does not depend on the model deciding to ask |
| the order the person is already looking at | someone who opens ORD-10001 in the UI and starts asking questions should have that order in the conversation before the first question |
A Fixed Resource
The course project already has the policy documents on the classpath, at order-service/src/main/resources/policies/returns.md and shipping.md. Nothing reads them yet. Create order-service/src/main/java/com/themcpguy/supportdesk/orders/mcp/PolicyResources.java:
package com.themcpguy.supportdesk.orders.mcp;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.springframework.ai.mcp.annotation.McpResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
@Component
public class PolicyResources {
@McpResource(
uri = "policy://returns",
name = "returns_policy",
title = "Returns policy",
description = "The current returns policy, including the returns window and exclusions.",
mimeType = "text/markdown")
public String returnsPolicy() {
return read("policies/returns.md");
}
@McpResource(
uri = "policy://shipping",
name = "shipping_policy",
title = "Shipping policy",
description = "Carriers, delivery estimates and what happens to a delayed shipment.",
mimeType = "text/markdown")
public String shippingPolicy() {
return read("policies/shipping.md");
}
private String read(String path) {
try {
return StreamUtils.copyToString(
new ClassPathResource(path).getInputStream(), StandardCharsets.UTF_8);
}
catch (IOException e) {
throw new IllegalStateException("Could not read " + path, e);
}
}
}
mimeType names the format so a client knows how to read what it receives, and it also decides what Spring AI wraps the returned String in: text/markdown starts with text/, so the string comes back in a text field. A resource method may return other types too:
| Return type | What the client receives |
|---|---|
String with a text-ish mimeType | one TextResourceContents, the string in text. Text-ish means starting with text/, or one of application/json, application/xml, application/javascript, application/ecmascript, application/xhtml+xml and application/x-httpd-php, or ending in +json or +xml |
String with any other mimeType | one BlobResourceContents, the string in blob, where the specification expects base64 |
ResourceContents | passed through as it is |
List<String> | one content per element, text or blob by the same rule |
List<ResourceContents> | passed through as they are |
ReadResourceResult | returned unchanged, so the method controls the whole reply |
Declare application/pdf and return readable text, and that text goes into blob, where every client that decodes it as base64 gets nonsense.
uri is ours to choose. policy:// is not a registered scheme and does not need to be: within MCP a resource URI is an identifier, and the scheme groups them. The specification does require a custom scheme to be a valid RFC 3986 scheme name: a letter, then letters, digits, +, - or .. Both policy and order qualify.
A Resource Template
We cannot write an annotated method for every order. @McpResource takes a URI template instead: a URI with a named placeholder in braces, so one method answers for a family of resources, and the placeholder becomes a method parameter:
package com.themcpguy.supportdesk.orders.mcp;
import tools.jackson.databind.ObjectMapper;
import org.springframework.ai.mcp.annotation.McpResource;
import org.springframework.stereotype.Component;
import com.themcpguy.supportdesk.orders.service.OrderService;
@Component
public class OrderResources {
private final OrderService orderService;
private final ObjectMapper objectMapper;
OrderResources(OrderService orderService, ObjectMapper objectMapper) {
this.orderService = orderService;
this.objectMapper = objectMapper;
}
@McpResource(
uri = "order://{orderId}",
name = "order",
title = "Order",
description = "A single order, as JSON. The orderId looks like ORD-10001.",
mimeType = "application/json")
public String order(String orderId) {
return orderService.findById(orderId)
.map(objectMapper::writeValueAsString)
.orElseThrow(() -> new IllegalArgumentException("No order with ID " + orderId));
}
}
Spring AI fills the placeholders in the order they appear in the template, into the method's parameters in declaration order. The name is a convention, and orderId is worth keeping because it makes the method readable. A placeholder parameter has to be declared as String; any other type is rejected with IllegalArgumentException: URI variable parameters must be of type String.
Spring AI recognises the simple {name} form, the first level of RFC 6570:
| Template syntax | Supported |
|---|---|
{name}, standing for one path-like segment | yes |
a captured value containing a / | no, the segment ends at the slash |
the RFC's operators {+var}, {#var}, {?q} and {/path*} | no |
A resource method cannot return an arbitrary object, so a method that declares application/json has to produce the JSON itself. Spring Boot's auto-configured mapper is injected for that and does it in one call. Returning the Order record instead is rejected while the Spring context is being built, so the application does not start:
IllegalArgumentException: Method must return either ReadResourceResult, List<ResourceContents>,
List<String>, ResourceContents, or String: order in
com.themcpguy.supportdesk.orders.mcp.OrderResources returns
com.themcpguy.supportdesk.orders.domain.Order
Spring Boot 4 uses Jackson 3, so the import is tools.jackson.databind.ObjectMapper rather than the com.fasterxml package Jackson 2 used, and writeValueAsString no longer declares a checked exception, because JacksonException extends RuntimeException.
Read Them Over curl
Restart and open a session, as in Class 2.
Opening a session: the two calls from Class 2 (click to expand)
initialize first. The Accept header names both content types, because the server chooses which one it replies with:
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"}}}'
The reply carries an Mcp-Session-Id header. Copy it, then 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"}'
That comes back as 202 with an empty body. Restarting the server ends the session, so both calls are needed again after every restart.
On Windows: the same calls in PowerShell (click to expand)
The commands in this section need three changes in PowerShell:
| What changes | Why | What to write |
|---|---|---|
| the command name | Windows PowerShell treats curl as an alias for Invoke-WebRequest, which does not take these flags | curl.exe |
| the line continuation | PowerShell continues a line with a backtick rather than a backslash | a backtick at the end of each line |
| the JSON body | in PowerShell 7.3 or later the single-quoted body after -d works as written, because PowerShell passes the argument to the program intact. Windows PowerShell 5.1, the version built into Windows, rebuilds the command line and the inner double quotes do not survive, so the server receives invalid JSON | save the body to a file and pass it by name |
Writing the body out first works in both versions, so the initialize call becomes:
@'
{"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"
The same three changes carry through the rest of the section: write each JSON body to body.json first, and keep every header the call sends, Mcp-Session-Id included.
Then list what is there:
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":"resources/list","params":{}}'
{
"result": {
"resources": [
{ "uri": "policy://shipping", "name": "shipping_policy",
"description": "Carriers, delivery estimates and what happens to a delayed shipment.",
"mimeType": "text/markdown" },
{ "uri": "policy://returns", "name": "returns_policy",
"description": "The current returns policy, including the returns window and exclusions.",
"mimeType": "text/markdown" }
]
}
}
The reply differs from the annotations in two ways:
| Attribute | In resources/list | Note |
|---|---|---|
uri | yes | the identifier the client reads with |
name | yes | falls back to the method name when left blank |
title | no | accepted by the annotation, dropped by Spring AI 2.0.0 |
description | yes | what a person picking a resource reads |
mimeType | yes | also decides text or blob, as in the table above |
The listing does not follow declaration order: the server keeps its resources in a map keyed by URI and walks that map, so policy://shipping prints first. Treat the order as arbitrary.
resources/list is also paginated, through a nextCursor the client sends straight back as params.cursor. MCP Fundamentals Class 4 covers the cursor rules. Two policies fit in one page here.
The template is in the other list:
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":"resources/templates/list","params":{}}'
{
"result": {
"resourceTemplates": [
{ "uriTemplate": "order://{orderId}", "name": "order",
"description": "A single order, as JSON. The orderId looks like ORD-10001.",
"mimeType": "application/json" }
]
}
}
Read one:
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":4,"method":"resources/read","params":{"uri":"policy://returns"}}'
{
"result": {
"contents": [
{ "uri": "policy://returns", "mimeType": "text/markdown",
"text": "# Returns Policy\n\nEffective 1 January 2026. Supersedes the 2023 policy.\n\n## The returns window\n\nCustomers may return most items within **30 days of delivery** ..." }
]
}
}
And through the template:
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":5,"method":"resources/read","params":{"uri":"order://ORD-10001"}}'
{
"result": {
"contents": [
{
"uri": "order://ORD-10001",
"mimeType": "application/json",
"text": "{\"orderId\":\"ORD-10001\",\"status\":\"SHIPPED\",\"customer\":{\"customerId\":\"CUST-42\", ... },\"totalAmount\":179.99, ... }"
}
]
}
}
order://ORD-99999 matches the template, so our method runs and throws an exception, which Spring AI turns into an Invalid Params error:
{
"error": {
"code": -32602,
"message": "Error invoking resource method: order in com.themcpguy.supportdesk.orders.mcp.OrderResources. /nCause: No order with ID ORD-99999"
}
}
unknown://thing does not match any fixed resource or template, so it does not reach a method of ours, and the server answers with the protocol's own code for a missing resource:
{
"error": {
"code": -32002,
"message": "Resource not found",
"data": { "uri": "unknown://thing" }
}
}
This is the route from a URI to one of those two codes:
The split at the bottom is the step to remember: a missing order still matches the template, so it reaches our method. A method can also send -32002 deliberately, by throwing an McpError built with McpSchema.ErrorCodes.RESOURCE_NOT_FOUND.
Which code means "resource not found" depends on the revision:
| Revision | Resource not found |
|---|---|
2025-11-25, the one this class targets | -32002 |
2026-07-28 | -32602, and clients are asked to keep accepting -32002 |
OrderResources.order hands back a whole order, customer details included, to any client that can open a session, and ORD-10001 invites ORD-10002. The specification's security considerations say access controls SHOULD be implemented for sensitive resources, and OWASP calls a fetch without one an insecure direct object reference. Class 17 covers the authorization the check needs, which starts with knowing who is asking: the next section.
The Same Data as a Tool and as a Resource
order://ORD-10001 and the get_order tool return the same order. The overlap is deliberate, because the two are reached differently:
get_order | order://{orderId} | |
|---|---|---|
| Who asks for it | the model, when it works out it needs an order | our code, when it already knows which order |
| What has to happen first | the model has to name the order ID | the person has to open an order |
| How a client finds it | tools/list | resources/templates/list |
| The call | tools/call | resources/read |
In Class 8 we use both from the client side, attaching the policy before the first question and leaving get_order for what our code did not anticipate.
Metadata on the Request
A resource method can take an McpMeta parameter to see the _meta field of the request:
import org.springframework.ai.mcp.annotation.McpMeta;
@McpResource(uri = "order://{orderId}", ...)
public String order(String orderId, McpMeta meta) {
// meta carries whatever the client attached to this request
return ...;
}
It is empty unless a client puts something there, and it does not become part of the URI template, so clients see the same resource. A request carrying metadata:
{
"jsonrpc": "2.0",
"id": 6,
"method": "resources/read",
"params": {
"uri": "order://ORD-10001",
"_meta": {
"com.themcpguy/supportAgentId": "agent-17"
}
}
}
meta.get("com.themcpguy/supportAgentId") then returns agent-17. The specification asks for a prefix of dot-separated labels followed by a slash, in reverse DNS notation, and reserves any prefix whose second label is modelcontextprotocol or mcp, which leaves com.themcpguy/ ours to use.
In Class 13 we set _meta from the client side with ToolContextToMcpMetaConverter. That is how application context, such as which user is asking, reaches the server without being a tool argument the model can see or change.
What We Built
order-service now exposes two fixed resources and one template alongside its four tools. The capabilities in the handshake have reported resources since Class 2, and the list behind it now has entries.
In Class 5 we add prompts, the third primitive.
Next: Class 5: Prompts and Completion. A reusable refund-email prompt, and completing order IDs as its argument is filled in.
Further Reading
- MCP specification: Resources: the normative rules behind this class. It covers the three methods, the
ResourceandResourceTemplatefields, the text and blob content shapes, the error codes and the security considerations. - Understanding MCP servers: tools, resources and prompts set side by side, with a "Who controls it" column reading model, application, person.
- MCP Server Annotations, Spring AI reference: the full
@McpResourceattribute table, including themetaProviderandannotationsattributes this class does not use. - MCP Annotations Special Parameters, Spring AI reference: every parameter type an annotated method may take beyond its own arguments,
McpMetaamong them. - RFC 6570: URI Template: the full template syntax, of which Spring AI implements the simple
{name}form used here. - RFC 3986: Uniform Resource Identifier (URI), Generic Syntax: the definition of a URI and of a scheme name, which a custom scheme such as
policy://has to conform to. - MCP specification: Pagination: how
cursorandnextCursorwork onresources/listonce a server has more resources than fit in one reply. - MCP specification: Completion: how a client offers suggestions for a template placeholder as it is typed, which is the mechanism we use in Class 5 to complete order IDs.
- Insecure Direct Object Reference Prevention, OWASP: why an identifier taken from the caller and used to fetch a record needs an authorization check between the two.
Sources
- MCP specification: Resources: that resources are identified by a URI and are application-driven, and the method names
resources/list,resources/templates/listandresources/read. It also gives the text and blob content shapes and requires a custom URI scheme to conform to RFC 3986. It sets-32002as the code for a resource that does not exist in this revision, and says access controls SHOULD be implemented for sensitive resources. - MCP specification: Resources,
2026-07-28: that the newer stable revision changed the resource-not-found code to-32602and asks clients to keep accepting-32002. - Understanding MCP servers: that a tool is controlled by the model, a resource by the application and a prompt by the person.
- MCP specification: Base Protocol: the
_metakey name format, the recommendation to use reverse DNS notation for a prefix, and the reservation of any prefix whose second label ismodelcontextprotocolormcp. - MCP Server Annotations, Spring AI reference: the
@McpResourceattributesuri,name,title,descriptionandmimeType, withtitledocumented as an optional display name. - MCP Annotations Special Parameters, Spring AI reference: that
McpMetais injected automatically, is excluded from parameter counting, and arrives empty when the request does not carry any metadata. - Jackson Release 3.0: the package move from
com.fasterxml.jacksontotools.jackson, and the replacement of the checkedJsonProcessingExceptionwith an uncheckedJacksonExceptionwhose parent isRuntimeException. - JSON, Spring Boot reference: that Spring Boot auto-configures a
JsonMapperbean when Jackson is on the classpath, which is the beanOrderResourcesreceives as anObjectMapper. - Uniform Resource Identifier (URI) Schemes, IANA: that
policyandorderare absent from the registry of URI schemes. - MCP specification: Pagination: that
resources/listis paginated throughcursorandnextCursor.