Skip to main content

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
  • @McpResource with a fixed URI and with a URI template
  • What a resource method may return
  • resources/list, resources/templates/list and resources/read over curl
  • McpMeta, for the metadata that arrives with a request
Companion code

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:

PrimitiveWho decides it is neededWhere in this course
Toolthe modelClass 3
Resourcethe applicationthis class
Promptthe personClass 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:

WhatWhy it belongs in the request from the start
the policy documentsan 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 atsomeone 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 typeWhat the client receives
String with a text-ish mimeTypeone 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 mimeTypeone BlobResourceContents, the string in blob, where the specification expects base64
ResourceContentspassed through as it is
List<String>one content per element, text or blob by the same rule
List<ResourceContents>passed through as they are
ReadResourceResultreturned 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 syntaxSupported
{name}, standing for one path-like segmentyes
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:

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"}}}'

The reply carries an Mcp-Session-Id header. Copy it, then acknowledge the handshake, which the protocol requires before anything else:

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' \
-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 changesWhyWhat to write
the command nameWindows PowerShell treats curl as an alias for Invoke-WebRequest, which does not take these flagscurl.exe
the line continuationPowerShell continues a line with a backtick rather than a backslasha backtick at the end of each line
the JSON bodyin 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 JSONsave the body to a file and pass it by name

Writing the body out first works in both versions, so the initialize call becomes:

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"

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:

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' \
-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:

AttributeIn resources/listNote
uriyesthe identifier the client reads with
nameyesfalls back to the method name when left blank
titlenoaccepted by the annotation, dropped by Spring AI 2.0.0
descriptionyeswhat a person picking a resource reads
mimeTypeyesalso 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:

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' \
-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:

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' \
-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:

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' \
-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:

RevisionResource not found
2025-11-25, the one this class targets-32002
2026-07-28-32602, and clients are asked to keep accepting -32002
The order resource returns any order to anyone who asks

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_orderorder://{orderId}
Who asks for itthe model, when it works out it needs an orderour code, when it already knows which order
What has to happen firstthe model has to name the order IDthe person has to open an order
How a client finds ittools/listresources/templates/list
The calltools/callresources/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

Sources

  • MCP specification: Resources: that resources are identified by a URI and are application-driven, and the method names resources/list, resources/templates/list and resources/read. It also gives the text and blob content shapes and requires a custom URI scheme to conform to RFC 3986. It sets -32002 as 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 -32602 and 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 _meta key name format, the recommendation to use reverse DNS notation for a prefix, and the reservation of any prefix whose second label is modelcontextprotocol or mcp.
  • MCP Server Annotations, Spring AI reference: the @McpResource attributes uri, name, title, description and mimeType, with title documented as an optional display name.
  • MCP Annotations Special Parameters, Spring AI reference: that McpMeta is 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.jackson to tools.jackson, and the replacement of the checked JsonProcessingException with an unchecked JacksonException whose parent is RuntimeException.
  • JSON, Spring Boot reference: that Spring Boot auto-configures a JsonMapper bean when Jackson is on the classpath, which is the bean OrderResources receives as an ObjectMapper.
  • Uniform Resource Identifier (URI) Schemes, IANA: that policy and order are absent from the registry of URI schemes.
  • MCP specification: Pagination: that resources/list is paginated through cursor and nextCursor.