Skip to main content

Class 4: Resources

Duration: ~30 minutes | Level: Intermediate | Prerequisites: Class 3: Tools in Depth. Still no API key and no 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

Tools and Resources Answer Different Questions

Class 3 gave the model four tools. It chooses which to call and when, based on the description and the conversation. A tool is something the model decides to use.

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 that is the person clicking a file. In our own agent, from Class 8, it is our code deciding that the returns policy belongs in this conversation. The model reads what it is given and does not go looking.

MCP Fundamentals puts it as application-controlled rather than model-controlled, and the practical difference is about when the data arrives. A tool call happens partway through, after the model has decided it needs something. A resource is there from the start.

Two things in order-service fit that shape:

  • The policy documents. A support conversation almost always needs the returns policy. Waiting for the model to ask for it costs a round trip and depends on the model realising it should.
  • An order the person is already looking at. If someone opens ORD-10001 in the UI and starts asking questions, that order should be in the conversation before the first question, not fetched again by a tool call.

A Fixed Resource

The starter 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);
}
}
}

The method returns a String, and Spring AI wraps it as TextResourceContents using the mimeType we declared. A resource method can also return ResourceContents, a List of either type, or a full ReadResourceResult when it needs to control the response completely. The String form covers most cases.

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 is a way of grouping.


A Resource Template

Every order cannot have its own annotated method. @McpResource takes a URI template instead, and the placeholder becomes a method parameter:

package com.themcpguy.supportdesk.orders.mcp;

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;

OrderResources(OrderService orderService) {
this.orderService = orderService;
}

@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(Object::toString)
.orElseThrow(() -> new IllegalArgumentException("No order with ID " + orderId));
}
}

The parameter name has to match the placeholder. {orderId} fills the orderId parameter, and a client reading order://ORD-10001 gets that order.

Templates are reported separately from fixed resources. resources/list returns the two policies; resources/templates/list returns this one.


Read Them Over curl

Restart, open a session as in Class 2, 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://returns", "name": "returns_policy", "title": "Returns policy",
"description": "The current returns policy, including the returns window and exclusions.",
"mimeType": "text/markdown" },
{ "uri": "policy://shipping", "name": "shipping_policy", "title": "Shipping policy",
"description": "Carriers, delivery estimates and what happens to a delayed shipment.",
"mimeType": "text/markdown" }
]
}
}

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", "title": "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\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"}}'

The Same Data Through Two Doors

order://ORD-10001 and the get_order tool return the same order. That looks like duplication and is not, because the two are reached differently.

The tool is for the model to call when it works out that it needs an order. The resource is for our code to attach when it already knows which order the conversation is about. A support agent looking at ORD-10001 in the browser should not need the model to guess that it should fetch ORD-10001.

Class 8 uses both from the client side, and the difference becomes concrete there.


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 adding the parameter does not change the schema. Class 13 sets it from the client side with ToolContextToMcpMetaConverter, which is where it becomes useful: it 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 reported resources from Class 2 onwards; the list behind it is no longer empty.

Class 5 adds the third primitive.


Next: Class 5: Prompts and Completion. A reusable refund-email prompt, and completing order IDs as its argument is filled in.