Class 4: Implementing Resources
Duration: ~85 minutes | Level: Intermediate | Prerequisites: Class 3: Implementing Tools
This class builds directly on Class 3. 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/mcp-java-sdk-course.git
Resources vs Tools: Who Chooses
The difference is not that Tools write and Resources read: plenty of Tools only read, and you built two of them last class. The difference is who decides that the data is needed.
The specification puts it as a single column:
| Primitive | Who controls it |
|---|---|
| Tools | Model |
| Resources | Application |
| Prompts | User |
A Tool is model-controlled: Claude reads your descriptions and decides, mid-conversation, to call one. That is why search_customers fired on its own last class.
A Resource is application-controlled: the host lists what is available and the user or the application picks what goes into the conversation. The host is the application that runs the MCP client and talks to the model, Claude Desktop in this course. Claude Desktop shows resources in the attachment menu, and it does not read them until someone attaches one.
The same question travels two different paths depending on which primitive answers it:
In the first path the model has to choose to fetch the data. In the second it is already there, and nobody asked the model whether it wanted it.
Everything you build in this class will sit in Claude Desktop's + menu waiting to be attached. Asking Claude "read the customer directory resource" will not usually make it happen, because resources are not the model's to call.
That is the primitive working as designed, and it is why search_customers is a Tool: the model has to be able to reach for it unprompted.
The question when choosing is: who knows the data is relevant? If the model has to work it out mid-conversation, that is a Tool, even a read-only one. If the user knows up front, or the application can decide by itself, that is a Resource.
What We'll Build
One server exposing the customer data from Class 3 three different ways, plus live update notifications:
| URI | Kind | Returns |
|---|---|---|
customers://directory | static resource | every customer, as JSON |
customers://{ customerId } | template | one customer and its contacts |
customers://{ customerId }/badge.png | binary template | a generated 128x128 PNG |
Plus add_contact from Class 3, so that changing the data fires a real notifications/resources/updated.
Where This Code Goes
Same project, same pattern as last class: a new package, a new server class, and a new entry in claude_desktop_config.json. ToolsMcpServer and HelloMcpServer both keep working.
src/main/java/com/themcpguy/
├── HelloMcpServer.java Class 2, untouched
├── tools/ Class 3, one small addition
│ └── CustomerRepository.java <- gains two read methods
└── resources/ <- new package, all of Class 4
├── CustomerDirectoryResource.java
├── CustomerProfileResource.java
├── CustomerBadgeResource.java
├── NotifyingCustomerRepository.java
└── ResourcesMcpServer.java this class's entry point
First, two methods on the repository
Class 3's CustomerRepository can search and add, but resources need to read the whole directory and one customer's contacts. Add these two declarations to the interface:
/** Every customer, for the directory resource in Class 4. */
CompletableFuture<List<Customer>> allAsync();
/** The people at one customer, for the profile resource in Class 4. */
CompletableFuture<List<Contact>> contactsForAsync(String customerId);
And their implementations inside InMemory:
@Override
public CompletableFuture<List<Customer>> allAsync() {
return CompletableFuture.supplyAsync(() -> List.copyOf(customers));
}
@Override
public CompletableFuture<List<Contact>> contactsForAsync(String customerId) {
return CompletableFuture.supplyAsync(() -> contacts.stream()
.filter(c -> c.customerId().equalsIgnoreCase(customerId))
.toList());
}
InMemory is the only class implementing this interface, and it lives in the same file, so those two edits are all that is needed.
A Static Resource
"Static" describes the address, not the data. The URI is fixed; the contents are read fresh on every request.
Create src/main/java/com/themcpguy/resources/CustomerDirectoryResource.java:
package com.themcpguy.resources;
import com.themcpguy.tools.CustomerRepository;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncResourceSpecification;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
import io.modelcontextprotocol.spec.McpSchema.Resource;
import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.IOException;
import java.util.List;
/**
* A static resource: one fixed URI, whose contents are read fresh on every request.
* "Static" describes the address, not the data.
*/
public final class CustomerDirectoryResource {
public static final String URI = "customers://directory";
private final McpJsonMapper jsonMapper;
private final CustomerRepository repository;
public CustomerDirectoryResource(McpJsonMapper jsonMapper, CustomerRepository repository) {
this.jsonMapper = jsonMapper;
this.repository = repository;
}
public AsyncResourceSpecification spec() {
Resource definition = Resource.builder(URI, "Customer directory")
.description("Every customer on file, with id, name, billing email and account status.")
.mimeType("application/json")
.build();
return new AsyncResourceSpecification(definition, (exchange, request) ->
Mono.fromCallable(() -> read(request.uri()))
.subscribeOn(Schedulers.boundedElastic()));
}
ReadResourceResult read(String uri) {
List<CustomerRepository.Customer> customers = repository.allAsync().join();
return ReadResourceResult.builder(List.of(
TextResourceContents.builder(uri, toJson(customers))
.mimeType("application/json")
.build())).build();
}
private String toJson(Object value) {
try {
return jsonMapper.writeValueAsString(value);
} catch (IOException e) {
// Resources do not have an isError flag. Throwing is how a read fails, and the
// SDK turns it into a JSON-RPC error the client can report.
throw new IllegalStateException("Could not serialise the directory: " + e.getMessage(), e);
}
}
}
A Resource is mostly metadata. Resource.builder(uri, name) takes the two fields the protocol requires and leaves the rest chained, exactly like Tool.builder(...) in Class 2, and the contents follow the same rule on the way out. Each call lands in one field of what the client receives:
| Builder call | Field the client sees | Required by the protocol | Who reads it |
|---|---|---|---|
Resource.builder(uri, ...) | uri | yes | the client, as the address to read |
Resource.builder(..., name) | name | yes | the host's picker |
.description(...) | description | no | a person choosing from a list |
.mimeType("application/json") | mimeType | no | the host, deciding how to render it |
TextResourceContents.builder(uri, ...) | contents[].uri | yes | the client, matching the reply to the request |
TextResourceContents.builder(..., text) | contents[].text | yes | whatever consumes the data |
.mimeType(...) on the contents | contents[].mimeType | no | the host, deciding how to render it |
A MIME type is the short label that says what kind of bytes these are: application/json here, image/png later. Write the description for a person choosing from a list, not for a model, because resources are application-controlled and a human is usually the one reading it.
Watch the argument order on the contents. The record's fields are (uri, mimeType, text), but the builder's arguments are (uri, text), because mimeType is optional and optional things do not become arguments. Reading the record and guessing would put them the wrong way round and still compile.
The URI appears twice, once on the resource definition and once on the contents you return, so a reply says which address it answers for:
{"contents":[{"uri":"customers://directory","mimeType":"application/json","text":"[{\"id\":\"CUST-1\", ... }]"}]}
For a static resource the two are the same string. For the template coming next they differ: the definition carries the pattern, the contents carry the concrete URI that was read.
A ReadResourceResult leaves out isError. Tools return a CallToolResult carrying an error flag the model can reason about. A resource read either produces contents or throws an exception, which the SDK turns into a JSON-RPC error. That is the whole error protocol for resources.
The handler returns a plain ReadResourceResult, and spec() wraps it in a Mono. Same split as the tools in Class 3: the logic is ordinary Java you can call from a test, and Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()) is the wiring. The repository returns futures, so join() blocks, and boundedElastic is the pool where blocking belongs.
A Resource Template
One customer is not one resource. Registering a resource per customer would mean re-registering every time somebody signs up. A template registers the shape of the URI instead.
Create src/main/java/com/themcpguy/resources/CustomerProfileResource.java. It is built with ResourceTemplate.builder(uriTemplate, name), the same shape as Resource.builder(uri, name), with the pattern taking the place of the fixed URI:
package com.themcpguy.resources;
import com.themcpguy.tools.CustomerRepository;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncResourceTemplateSpecification;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
import io.modelcontextprotocol.util.DefaultMcpUriTemplateManager;
import io.modelcontextprotocol.util.McpUriTemplateManager;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* A resource template: one pattern that answers for every customer, instead of one
* registration per customer.
*/
public final class CustomerProfileResource {
public static final String TEMPLATE = "customers://{customerId}";
/** The SDK's RFC 6570 parser, which turns a matched URI back into its variables. */
private static final McpUriTemplateManager URI_TEMPLATE = new DefaultMcpUriTemplateManager(TEMPLATE);
private final McpJsonMapper jsonMapper;
private final CustomerRepository repository;
public CustomerProfileResource(McpJsonMapper jsonMapper, CustomerRepository repository) {
this.jsonMapper = jsonMapper;
this.repository = repository;
}
/** The concrete URI for one customer, used when firing update notifications. */
public static String uriFor(String customerId) {
return "customers://" + customerId;
}
public AsyncResourceTemplateSpecification spec() {
ResourceTemplate definition = ResourceTemplate.builder(TEMPLATE, "Customer profile")
.description("One customer with the people who work there. Ids look like CUST-2.")
.mimeType("application/json")
.build();
return new AsyncResourceTemplateSpecification(definition, (exchange, request) ->
Mono.fromCallable(() -> read(request.uri()))
.subscribeOn(Schedulers.boundedElastic()));
}
ReadResourceResult read(String uri) {
String customerId = URI_TEMPLATE.extractVariableValues(uri).get("customerId");
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("no customer id in: " + uri);
}
CustomerRepository.Customer customer = repository.searchAsync(customerId, 1).join().stream()
.filter(c -> c.id().equalsIgnoreCase(customerId))
.findFirst()
.orElseThrow(() -> McpError.RESOURCE_NOT_FOUND.apply(uri));
List<CustomerRepository.Contact> contacts = repository.contactsForAsync(customerId).join();
// LinkedHashMap, not Map.of: Map.of does not define an iteration order, so the fields
// would come out shuffled, and differently on each JVM run.
Map<String, Object> profile = new LinkedHashMap<>();
profile.put("id", customer.id());
profile.put("name", customer.name());
profile.put("email", customer.email());
profile.put("accountStatus", customer.accountStatus());
profile.put("contacts", contacts);
String json = toJson(profile);
return ReadResourceResult.builder(List.of(
TextResourceContents.builder(uri, json)
.mimeType("application/json")
.build())).build();
}
private String toJson(Object value) {
try {
return jsonMapper.writeValueAsString(value);
} catch (IOException e) {
throw new IllegalStateException("Could not serialise the profile: " + e.getMessage(), e);
}
}
}
McpError.RESOURCE_NOT_FOUND rather than a plain exception. Throwing an exception is how a read fails, and this is how you throw one the protocol understands. Any ordinary exception reaches the client as -32603, INTERNAL_ERROR, which claims your server broke when the caller asked for something that does not exist. The specification reserves -32002 for that case:
| What the handler throws | code | message | data | What the caller concludes |
|---|---|---|---|---|
McpError.RESOURCE_NOT_FOUND.apply(uri) | -32002 | Resource not found | {"uri":"customers://NOPE"} | I asked for something that does not exist |
McpError.builder(code).message(...).data(...).build() | whatever you pass | yours | yours | whatever you chose to say |
any other exception, say NoSuchElementException | -32603 | the exception message | the exception class and its message | the server is broken |
McpSchema.ErrorCodes names those numbers, so you do not have to write them by hand.
LinkedHashMap rather than Map.of. Map.of does not define an iteration order, and it re-randomises between JVM runs, so the same customer would serialise with its fields in a different order every time you restarted the server. Two readings of the same resource then stop being comparable, which makes debugging harder.
The SDK routes a read of customers://CUST-2 to the handler registered for customers://{customerId}, and it will hand you the whole URI string. The SDK does not give you a request.parameter("customerId"), so it is tempting to reach for substring and be done.
Don't. io.modelcontextprotocol.util.DefaultMcpUriTemplateManager is public API and does the job properly:
var template = new DefaultMcpUriTemplateManager("customers://{customerId}");
template.getVariableNames(); // [customerId]
template.extractVariableValues("customers://CUST-2"); // {customerId=CUST-2}
template.matches("customers://CUST-2/badge.png"); // false
That last line is the one a parser you write yourself gets wrong. The specification defines resource templates as RFC 6570 URI templates, and the SDK's default parser reads the plain {name} form of that syntax: each variable becomes a capture group that stops at a /. So customers://{customerId} does not match customers://CUST-2/badge.png, and a template with three variables fills all three from three segments:
new DefaultMcpUriTemplateManager("customers://{region}/{customerId}/orders/{orderId}")
.getVariableNames(); // [region, customerId, orderId]
extractVariableValues returns an empty map rather than throwing an exception when nothing matches, which is why both resources check the result before using it.
customers://directory also fits customers://{customerId}. The SDK tries the concrete resources before the templates, so the directory answers, and a customer whose id was directory would be unreachable. An McpUriTemplateManagerFactory swaps the implementation across a whole server.
customerId arrives from the client, and the handler returns the customer's name, billing email and every contact. The specification puts three rules on that:
- Servers MUST validate all resource URIs.
- Access controls SHOULD be implemented for sensitive resources.
- Resource permissions SHOULD be checked before operations.
In-memory ids make this harmless here. The same handler over a file path or a database row hands the record to anyone who can guess a URI, and .. inside an id walks out of the directory you meant to serve. uriFor also builds its URI by concatenation, which holds only while ids are letters, digits and hyphens: an id that a person can choose has to be percent-encoded to stay a valid RFC 3986 URI. In Class 8 we read customers://CUST-2 back after somebody has written instructions for the model into a contact name, and watch that text travel into the conversation.
A Binary Resource
Resources are not limited to text. Swap TextResourceContents.builder(uri, text) for BlobResourceContents.builder(uri, base64) and everything else is identical.
Create src/main/java/com/themcpguy/resources/CustomerBadgeResource.java:
package com.themcpguy.resources;
import com.themcpguy.tools.CustomerRepository;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncResourceTemplateSpecification;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
import io.modelcontextprotocol.util.DefaultMcpUriTemplateManager;
import io.modelcontextprotocol.util.McpUriTemplateManager;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.List;
/**
* A binary resource template. Everything is the same as a text resource except the
* contents type: BlobResourceContents carries base64 instead of text.
* <p>
* The badge is drawn on the fly so the example works without image files on disk.
*/
public final class CustomerBadgeResource {
public static final String TEMPLATE = "customers://{customerId}/badge.png";
private static final int SIZE = 128;
private static final McpUriTemplateManager URI_TEMPLATE = new DefaultMcpUriTemplateManager(TEMPLATE);
private final CustomerRepository repository;
public CustomerBadgeResource(CustomerRepository repository) {
this.repository = repository;
}
public AsyncResourceTemplateSpecification spec() {
ResourceTemplate definition = ResourceTemplate.builder(TEMPLATE, "Customer badge")
.description("A 128x128 PNG showing the customer's initials, tinted by account status.")
.mimeType("image/png")
.build();
return new AsyncResourceTemplateSpecification(definition, (exchange, request) ->
Mono.fromCallable(() -> read(request.uri()))
.subscribeOn(Schedulers.boundedElastic()));
}
ReadResourceResult read(String uri) {
String customerId = URI_TEMPLATE.extractVariableValues(uri).get("customerId");
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("no customer id in: " + uri);
}
CustomerRepository.Customer customer = repository.searchAsync(customerId, 1).join().stream()
.filter(c -> c.id().equalsIgnoreCase(customerId))
.findFirst()
.orElseThrow(() -> McpError.RESOURCE_NOT_FOUND.apply(uri));
byte[] png = drawBadge(initials(customer.name()), "ACTIVE".equals(customer.accountStatus()));
return ReadResourceResult.builder(List.of(
BlobResourceContents.builder(uri, Base64.getEncoder().encodeToString(png))
.mimeType("image/png")
.build())).build();
}
static String initials(String name) {
String[] words = name.trim().split("\\s+");
StringBuilder out = new StringBuilder();
for (String word : words) {
if (!word.isBlank() && out.length() < 2) {
out.append(Character.toUpperCase(word.charAt(0)));
}
}
return out.isEmpty() ? "?" : out.toString();
}
private static byte[] drawBadge(String initials, boolean active) {
BufferedImage image = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(active ? new Color(0x1F, 0x6F, 0x4A) : new Color(0x7A, 0x2E, 0x2E));
g.fillRect(0, 0, SIZE, SIZE);
g.setColor(Color.WHITE);
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 56));
var metrics = g.getFontMetrics();
int x = (SIZE - metrics.stringWidth(initials)) / 2;
int y = (SIZE - metrics.getHeight()) / 2 + metrics.getAscent();
g.drawString(initials, x, y);
} finally {
g.dispose();
}
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
ImageIO.write(image, "png", out);
return out.toByteArray();
} catch (IOException e) {
throw new IllegalStateException("Could not encode the badge: " + e.getMessage(), e);
}
}
}
Most of this file is drawing. The badge is rendered at request time with BufferedImage and ImageIO, so the example does not need image files on disk or an extra dependency. In a real server the bytes would come from a file store or a database column, and drawBadge would disappear. That is where a size limit belongs: base64 costs a third more bytes than the file itself, and the whole thing is held in memory and written as one line. The optional size field on the resource metadata, .size(Long) on the builder, tells a host how large the read will be.
The MCP part is only the return statement. Base64 rewrites arbitrary bytes as plain text so they can travel inside a JSON string:
| Text resource | Binary resource | |
|---|---|---|
| Contents type | TextResourceContents | BlobResourceContents |
| Builder | .builder(uri, text) | .builder(uri, base64) |
| Field on the wire | text | blob |
| What goes in it | the string as written | Base64.getEncoder().encodeToString(bytes) |
mimeType here | application/json | image/png |
| Size on the wire | the text | about a third larger than the bytes |
ReadResourceResult.builder(List.of(...)) wraps either one. Note the mimeType appears twice, once on the template metadata so a host knows what to expect before reading, and once on the contents so it knows what it actually got. Both are chained calls, which makes both easy to leave off. Set them: a host that is told to expect image/png and then receives contents that omit mimeType will usually fail to render them.
Wiring It Up
Two more files. First the piece that makes update notifications real rather than simulated. Create src/main/java/com/themcpguy/resources/NotifyingCustomerRepository.java:
package com.themcpguy.resources;
import com.themcpguy.tools.CustomerRepository;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
/**
* Wraps a repository so that adding a contact also announces which resources changed.
* <p>
* A decorator rather than a change to CustomerRepository itself: Class 3's code does
* not need to know that anything is subscribing to it.
*/
final class NotifyingCustomerRepository implements CustomerRepository {
private final CustomerRepository delegate;
private final Consumer<String> onCustomerChanged;
NotifyingCustomerRepository(CustomerRepository delegate, Consumer<String> onCustomerChanged) {
this.delegate = delegate;
this.onCustomerChanged = onCustomerChanged;
}
@Override
public CompletableFuture<Contact> addContactAsync(String customerId, String name, String email) {
return delegate.addContactAsync(customerId, name, email)
.thenApply(created -> {
onCustomerChanged.accept(created.customerId());
return created;
});
}
@Override
public CompletableFuture<List<Customer>> searchAsync(String query, int limit) {
return delegate.searchAsync(query, limit);
}
@Override
public CompletableFuture<List<Customer>> allAsync() {
return delegate.allAsync();
}
@Override
public CompletableFuture<List<Contact>> contactsForAsync(String customerId) {
return delegate.contactsForAsync(customerId);
}
}
A decorator is a class that implements the same interface, holds the real one, and adds behaviour on the way through. Then the server. Create src/main/java/com/themcpguy/resources/ResourcesMcpServer.java:
package com.themcpguy.resources;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.themcpguy.tools.AddContactTool;
import com.themcpguy.tools.CustomerRepository;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpAsyncServer;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.ResourcesUpdatedNotification;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.atomic.AtomicReference;
public class ResourcesMcpServer {
private static final Logger log = LoggerFactory.getLogger(ResourcesMcpServer.class);
public static void main(String[] args) throws Exception {
McpJsonMapper jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper());
var transportProvider = new StdioServerTransportProvider(jsonMapper);
// The repository needs to notify a server that does not exist yet. Hold a slot
// now and fill it in after build(). Nothing reads the slot until a client calls
// the tool, by which time it is set.
AtomicReference<McpAsyncServer> serverRef = new AtomicReference<>();
CustomerRepository customers = new NotifyingCustomerRepository(
CustomerRepository.inMemory(),
customerId -> notifyChanged(serverRef.get(), customerId));
var directory = new CustomerDirectoryResource(jsonMapper, customers);
var profile = new CustomerProfileResource(jsonMapper, customers);
var badge = new CustomerBadgeResource(customers);
var addContact = new AddContactTool(jsonMapper, customers);
McpAsyncServer server = McpServer.async(transportProvider)
.serverInfo("acme-resources", "1.0.0")
.capabilities(ServerCapabilities.builder()
.resources(true, true) // subscribe, listChanged
.tools(true)
.build())
.resources(directory.spec())
.resourceTemplates(profile.spec(), badge.spec())
.tools(addContact.spec())
.build();
serverRef.set(server);
log.info("acme-resources started (stdio); awaiting messages on stdin");
Thread.currentThread().join();
}
/** Announces that one customer's profile changed, and that the directory did too. */
private static void notifyChanged(McpAsyncServer server, String customerId) {
if (server == null) {
return;
}
server.notifyResourcesUpdated(
new ResourcesUpdatedNotification(CustomerProfileResource.uriFor(customerId))).subscribe();
server.notifyResourcesUpdated(
new ResourcesUpdatedNotification(CustomerDirectoryResource.URI)).subscribe();
log.info("notified subscribers that {} and the directory changed", customerId);
}
}
.resources(...) and .resourceTemplates(...) are separate calls, because they answer separate requests: resources/list returns the first, resources/templates/list returns the second. A client that only ever calls resources/list misses your templates.
.resources(true, true) on the capabilities is (subscribe, listChanged). The first says you honour resources/subscribe; the second says you will emit notifications/resources/list_changed when the set of resources changes.
The AtomicReference breaks a genuine circle. The repository needs a server to notify, the server needs tools built on the repository, and the server does not exist until build() returns. Holding an empty slot and filling it once build() returns is the simplest way out.
Why Would Anyone Do This?
If Claude Desktop is the only client you have ever used, resources look like a worse version of a tool: you attach something yourself that a tool would have fetched on its own. That reading stops holding as soon as the client is one you wrote.
Claude Desktop is a generic chat client. It does not know anything about your business, so all it can do is list what is available and let a person choose. In an application you write, attaching a resource is a few lines of code that run on every request.
The scenario
You work at Acme, building an internal support-desk assistant: a web application your support staff use all day. A ticket arrives from [email protected].
Your application does this, before the model is invoked at all:
- Match the sender's domain to
CUST-2. That is your business logic, and it is not a hard problem. - Call
resources/readoncustomers://CUST-2. - Put that JSON into the conversation.
- Now ask the model whatever the support agent typed.
The model did not make any decision. The account status, the contacts and the billing email are present on every ticket, current and identical every time.
Now build the same feature out of a tool. The model has to decide to call search_customers, invent a query from an email address, and get it right.
| Tool | Resource | |
|---|---|---|
| Is the data definitely there? | Only if the model chose to fetch it | Always, because your code put it there |
| Round trips before answering | Model, tool, model again | None |
| Same context on every ticket? | No, it depends what it decided | Yes |
| What you can record afterwards | a call and its arguments | a URI: customers://CUST-2 |
A resource has an address, and a tool call does not. You cannot bookmark a tool call, cache it, point a second system at it, render it in a sidebar, or write in an audit log that this answer came from customers://CUST-2. A URI is a stable name for a piece of data, and other software can refer to it by that name.
The clearest example in the wild
The PostgreSQL reference server, now archived and no longer maintained, split its job exactly along this line:
| Primitive | What it is | Why that side |
|---|---|---|
| Resource | postgres://<host>/<table>/schema, the column names and types of one table | needed on every question, and never varies with the question |
| Tool | query, which runs a read-only SQL statement | only the model can decide what to ask |
The schema could have been a get_table_schema tool. Then the model has to remember to call it before writing SQL, every time, and the day it forgets it invents a column name and the query fails. As a resource, the application loads the schema before the model writes a character.
The schema is a fact the model always needs and never chooses; the query is a decision only the model can make. Your customer data has the same shape: the record is the fact, the search is the decision.
When to reach for which
For a plain chat assistant, a tool is often the better choice, and the two read-only tools in Class 3 show that. Resources are the better choice when
- the data is needed every time, so letting the model choose only adds latency and risk,
- something other than the model already knows which record matters, such as a ticket, an open file, or a selected row,
- the same data has to be addressable from more than one place, or auditable after the fact,
- you want to be told when it changes rather than asking again, which is the subscription at the end of this class.
There is counter-evidence too. The official filesystem and git servers expose files and history as tools, not resources, and resources are the least used of the three primitives in the wild. That is a reason to choose deliberately rather than reaching for a resource because the data happens to be read-only.
So why did I have to attach it myself?
Because Claude Desktop is driven by a person. Its + menu is the fallback for a client that cannot know which record matters, so it asks you. When you own the client, that same resources/read runs on a trigger you choose: a ticket arriving, a page loading, a row being selected.
Application-controlled does not mean that a human clicks. It means the application decides, and Claude Desktop is the simplest kind of application that can do the deciding.
The click is not the protocol's idea either. The specification lists three ways a host may surface resources:
- Expose resources through UI elements for explicit selection, in a tree or list view
- Allow the user to search through and filter available resources
- Implement automatic context inclusion, based on heuristics or the AI model's selection
There is even a way for your server to ask for it. Resources carry optional annotations, including audience, which is user, assistant or both, and priority, a number from 0.0 to 1.0 where the spec describes 1 as "most important (effectively required)". A server can mark the directory as required reading for the model:
Resource.builder(URI, "Customer directory")
.annotations(Annotations.builder()
.audience(List.of(Role.ASSISTANT))
.priority(1.0)
.build())
// ... description and mimeType as before
.build();
A host that implements automatic inclusion may then load it without anyone clicking. Both fields are hints a host may ignore.
Template variables can also be filled in through the completion API, a separate request a host sends while a person is typing, so a picker can suggest CUST-2 before they finish the word.
Which is what the three pieces are for
| What you built | Who reads it in the support desk |
|---|---|
customers://directory | the app's customer picker, and Claude Desktop's + menu |
customers://{customerId} | the app, once per ticket, after it resolves the sender |
| the subscription | the app again, to refresh the panel when someone adds a contact |
One more route worth knowing. ResourceLink is a content type in the SDK, so a tool result can carry a pointer to a resource:
CallToolResult.builder()
.addTextContent("Found 1 customer.")
.addContent(ResourceLink.builder()
.uri("customers://" + customerId)
.name(name)
.mimeType("application/json")
.build())
.build();
search_customers finds Globex, its result points at customers://CUST-2, and the host reads that address:
The model did the finding, the application did the fetching, and the template is what gives the link somewhere to point.
A host asks two separate questions to learn what a server offers: resources/list returns concrete URIs, resources/templates/list returns patterns. Claude Desktop asks the first and shows the result in the + menu. It does not offer templates there, so you cannot ask it for customers://CUST-2.
| Host | How it reaches your resources |
|---|---|
| Claude Desktop | the + menu, listing what resources/list returned |
| Claude Code | @ mentions, listing resources in the autocomplete beside files |
So one of the three things you built is reachable from the host you have used all course, and that is not a flaw in your code. With Tools the model reaches for what it needs and every host behaves much the same. With Resources the host decides how much of your server is reachable.
Which is why the next section drives the server directly. When a resource does not appear, you need to know whether the fault is yours or the client's.
Try It
Two ways, and you want both: the host shows what a user gets, and the terminal shows what your server actually said, which is where most of this class is visible.
Add a third entry to claude_desktop_config.json, alongside the two you already have:
"acme-resources": {
"command": "java",
"args": [
"-cp",
"/absolute/path/to/mcp-java-sdk-course/target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar",
"com.themcpguy.resources.ResourcesMcpServer"
]
}
Build, then fully quit and reopen Claude Desktop:
mvn package
In Claude Desktop
Click + at the bottom left of the message box, find acme-resources, and you should see Customer directory offered as an attachment. Attach it and ask:
How many customers are on file, and is anyone suspended?
Claude answers from the JSON you attached. Note what did not happen: you did not ask it to fetch anything, and it did not choose to.
Then notice what you cannot do here. Opening customers://CUST-2 takes a different client, and the badge and the notification you wired up stay out of sight. Two thirds of this class is invisible to this host, and no amount of asking Claude will change that. So test it where it is visible.
From a terminal, as the client
The stdio transport is JSON, one message per line, arriving on the server's stdin and leaving on its stdout. That is what Claude Desktop writes and reads down the pipe it opens when it launches your process.
So launch the process yourself, and your terminal becomes the client:
java -cp target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar com.themcpguy.resources.ResourcesMcpServer
Nothing happens beyond a startup log, because the server is blocked waiting on stdin. Send the lines below one at a time and read each reply before sending the next. You are not simulating a client here; the server cannot tell the difference between you and Claude Desktop.
The handshake belongs to the connection. Steps 1 and 2 are not setup you do once for the class; they are the first two messages of this session. Restart the server for any reason and you begin again at step 1, because the new process did not see any of your earlier messages. Jumping straight back to step 3 gets you silence.
Every session opens the same way:
The third message does not carry an id, and it opens the session.
Every exchange below shows two blocks. The first is yours to paste. The second is what comes back, and pasting it in is the easiest mistake to make here, because your terminal echoes what you paste. A line you typed and a line the server printed look exactly alike on screen.
Feed a reply back in and you get:
WARN i.m.spec.McpServerSession - Unexpected response for unknown id 1
A message carrying result is a response, so the server goes looking for a request it sent with that id, does not find one, and says so. It is only a warning and the session survives it, but if you see that line, the last thing you pasted came out of the server instead of this page.
One other rule, and it is the stricter of the two: never press Enter on an empty line. A blank line kills the connection outright, and the step after next explains why.
Logback writes to stderr, so log lines land in the same terminal, interleaved with the replies. The protocol messages are the ones starting {"jsonrpc". This is why Class 1 insisted on logging to stderr: logs on stdout would sit inside the protocol stream, and a real client would fail to parse them.
Three sources land on one screen, and only what leaves through stdout is protocol.
1. Open the session. Every MCP conversation starts here, Claude Desktop's included.
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"logging":{},"resources":{"subscribe":true,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"acme-resources","version":"1.0.0"}}}
Read "resources":{"subscribe":true,"listChanged":true} back. That is the .resources(true, true) you wrote in ResourcesMcpServer, arriving at a client as a promise. The logging entry beside it is the SDK's own: McpAsyncServer adds it to whatever capabilities you built.
2. Say you are ready.
{"jsonrpc":"2.0","method":"notifications/initialized"}
Nothing comes back, and nothing is wrong. That line does not carry an id, which makes it a notification, and a notification does not get a reply. This is the one step in the session where the server is supposed to stay silent.
It is also the step that makes every later one work. You ask, the server answers, and you acknowledge. The specification puts a rule on either side of that acknowledgement.
- The client SHOULD NOT send requests other than pings before the server has responded to the
initializerequest.- The server SHOULD NOT send requests other than pings and logging before receiving the
initializednotification.
Until the acknowledgement lands, neither side knows the other has finished reading the negotiation. Your server has just announced that it supports subscribe, and it should not push a notifications/resources/updated at a client that has not yet confirmed it read that announcement.
This SDK enforces the rule, without saying so. Send resources/list before the notification and neither an answer nor an error comes back. The request is held, and the reply appears the instant notifications/initialized arrives:
Try it in that order: send resources/list, wait, then send the notification and watch the directory come back without your having asked twice.
That silence is the most misleading thing in the whole session, because a held request is indistinguishable from a hung server. If a request of yours goes unanswered, suspect the handshake before you suspect your code.
Silence is the correct result, so the natural reaction is to tap Enter to see if the thing is still alive. Do not. A blank line ends the session, and the server will ignore everything you send afterwards.
The SDK reads one line and hands it to Jackson, Jackson throws an exception on the empty line, and the inbound loop dies on it:
ERROR i.m.s.t.StdioServerTransportProvider - Error processing inbound message
com.fasterxml.jackson.databind.exc.MismatchedInputException: No content to map due to end-of-input
A line of spaces does the same thing. After that stack trace the process is still running and the prompt still accepts input, but nothing you send is ever answered again, which reads exactly like a hang. The session cannot recover: Ctrl+C and start the server again.
Nothing in your code can catch this. The failure happens inside StdioServerTransportProvider, reading the line before any of your handlers are consulted. Leaving it is safe: a real client always sends a complete JSON line, and this only comes up because you are typing at the transport yourself.
3. Ask what Claude Desktop asks.
{"jsonrpc":"2.0","id":2,"method":"resources/list","params":{}}
{"jsonrpc":"2.0","id":2,"result":{"resources":[{"uri":"customers://directory","name":"Customer directory","description":"Every customer on file, with id, name, billing email and account status.","mimeType":"application/json"}]}}
One resource. This is the + menu, in JSON, and you can see why only this one appeared in it.
4. Ask the question Claude Desktop does not.
{"jsonrpc":"2.0","id":3,"method":"resources/templates/list","params":{}}
{"jsonrpc":"2.0","id":3,"result":{"resourceTemplates":[{"uriTemplate":"customers://{customerId}","name":"Customer profile","description":"One customer with the people who work there. Ids look like CUST-2.","mimeType":"application/json"},{"uriTemplate":"customers://{customerId}/badge.png","name":"Customer badge","description":"A 128x128 PNG showing the customer's initials, tinted by account status.","mimeType":"image/png"}]}}
There is the rest of your server. .resources(...) and .resourceTemplates(...) were separate builder calls, and this is that separation showing up on the wire.
5. Read one customer. This is the first request that reaches the template handler you wrote:
{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"customers://CUST-2"}}
{"jsonrpc":"2.0","id":4,"result":{"contents":[{"uri":"customers://CUST-2","mimeType":"application/json","text":"{\"id\":\"CUST-2\",\"name\":\"Globex Industries\",\"email\":\"[email protected]\",\"accountStatus\":\"ACTIVE\",\"contacts\":[]}"}]}}
That reply shows three things: the SDK routed a concrete URI to the handler registered under a pattern, extractVariableValues pulled CUST-2 out of it, and the uri on the contents is the concrete address, not the template. contacts is empty because this is a fresh process with its own repository.
6. Read the badge. Same call, different content type:
{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"customers://CUST-2/badge.png"}}
{"jsonrpc":"2.0","id":5,"result":{"contents":[{"uri":"customers://CUST-2/badge.png","mimeType":"image/png","blob":"iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAFKUlEQVR4Xu2b/U9V ... }]}}
Roughly two thousand characters, trimmed here. blob rather than text is the only structural difference, and iVBORw0KGgo is what a PNG file header looks like once base64 has been applied. Note also which handler answered: customers://{customerId} did not match this URI, which is the rule from earlier: a variable stops at a /.
7. Read one that does not exist.
{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"customers://NOPE"}}
{"jsonrpc":"2.0","id":6,"error":{"code":-32002,"message":"Resource not found","data":{"uri":"customers://NOPE"}}}
There is an error where result usually sits. This is "resources do not have an isError" in practice: the exception thrown out of read became a JSON-RPC error beside the id. A tool in the same situation returns "isError":true inside an ordinary result:
| Tool call that fails | Resource read that fails | |
|---|---|---|
| Where it sits in the message | inside result | in error, beside the id |
| What marks it | "isError": true | a JSON-RPC code |
| Who is meant to see it | the model | the client application |
| What they do with it | reason about it and try something else | decide what to show the user |
| How your handler signals it | return a CallToolResult with the flag set | throw an exception out of read |
A model is meant to see a failed tool call and reason about it. The read was requested by the client application, so the failure travels back there, and the model is not in that loop.
The code is -32002, not -32603. That comes from McpError.RESOURCE_NOT_FOUND. Had you thrown a plain NoSuchElementException you would see -32603, INTERNAL_ERROR, which tells the caller your server has broken, when in fact it is working and they asked for a customer that does not exist. Try customers://NOPE/badge.png too; the badge template answers the same way.
Keep this session open. The next section uses it.
Live Updates
Most data worth exposing changes while a session is open, and MCP has a notification for that.
The wiring is already in place: NotifyingCustomerRepository calls back on every add, and notifyChanged turns that into two notifications/resources/updated, one for the customer whose contacts changed and one for the directory that lists them. You send three requests and get four messages back, because the server sent one of them on its own:
The notifications/resources/updated line is the only message here without an id.
Prove it in the session you already have open. Subscribe first:
{"jsonrpc":"2.0","id":7,"method":"resources/subscribe","params":{"uri":"customers://CUST-2"}}
{"jsonrpc":"2.0","id":7,"result":{}}
An empty result, which is the protocol's way of saying yes. Now change the data, using the Class 3 tool you registered alongside the resources:
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"add_contact","arguments":{"customerId":"CUST-2","name":"Dana Wu","email":"[email protected]"}}}
Two messages come back:
{"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"customers://CUST-2"}}
{"jsonrpc":"2.0","id":8,"result":{"content":[{"type":"text","text":"{\"id\":\"CONTACT-1\",\"customerId\":\"CUST-2\",\"name\":\"Dana Wu\",\"email\":\"[email protected]\"}"}],"isError":false}}
The first one does not carry an id. Nothing requested it. It is the server speaking first, which a plain request/response API cannot do, and the reason this primitive exists. It arrived before the reply to the call that caused it, because NotifyingCustomerRepository fires while the tool's own result is still being assembled. Nothing in the protocol fixes the order of a notification against a response, so do not build on it.
Read the profile again and Dana is there:
{"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"customers://CUST-2"}}
{"jsonrpc":"2.0","id":9,"result":{"contents":[{"uri":"customers://CUST-2","mimeType":"application/json","text":"{\"id\":\"CUST-2\",\"name\":\"Globex Industries\",\"email\":\"[email protected]\",\"accountStatus\":\"ACTIVE\",\"contacts\":[{\"id\":\"CONTACT-1\",\"customerId\":\"CUST-2\",\"name\":\"Dana Wu\",\"email\":\"[email protected]\"}]}"}]}}
Press Ctrl+C when you are done.
An application holding customers://CUST-2 on screen was told the moment the data went stale, and knew which URI to re-read, without polling and without asking a model anything.
Notice what you did not receive. The server fired notifications for both the profile and the directory, but only the profile arrived, because that is the only URI you subscribed to. The SDK filters per subscription. Fire notifications for everything that genuinely changed and let the client decide what it cares about.
What you just used is resources/subscribe, one request per URI. The ratified 2026-07-28 specification replaces it with a single long-lived subscriptions/listen stream:
| 2025-11-25, what you just used | 2026-07-28, ratified | |
|---|---|---|
| How you ask | resources/subscribe, one request per URI | one subscriptions/listen request |
| What carries the URIs | params.uri | the notifications.resourceSubscriptions array |
| How long it lasts | until resources/unsubscribe or the session ends | a long-lived stream |
| What comes back first | an empty result | notifications/subscriptions/acknowledged |
| What the server may push | whatever it chooses | only the notification types the client listed |
| Java SDK 2.0.0 | works | Method not found |
Ask the SDK for 2026-07-28 during initialize and it negotiates 2025-11-25, its own ceiling. So resources/subscribe is the only way that works here, and the code above is correct for the SDK you have.
Write your notification logic so the what changed part is separate from the how we announce it part, as notifyChanged is, and moving to subscriptions/listen later touches one method.
What We Built
src/main/java/com/themcpguy/resources/
├── CustomerDirectoryResource.java static, application/json
├── CustomerProfileResource.java template, application/json
├── CustomerBadgeResource.java template, image/png as base64
├── NotifyingCustomerRepository.java decorator that announces changes
└── ResourcesMcpServer.java three resources plus add_contact
Three servers now run side by side from one JAR: my-first-server echoes, acme-tools calculates and searches, acme-resources exposes data and notifies. Each is a separate process launched by Claude Desktop, and they do not share memory. Adding a contact through acme-tools will not change what acme-resources reports; each holds its own repository.
The pattern repeats across all three: a class per thing, a spec() that describes it to the protocol, and plain Java underneath that a test can call without a server running.
The Companion Code
This class is the class_4 branch, which is exactly the three servers above:
git clone --branch class_4 https://github.com/the-mcp-guy/mcp-java-sdk-course.git
What's Next
Tools are chosen by the model, Resources are chosen by the application. The third primitive is chosen by the user: Prompts are reusable templates a person invokes deliberately, usually from a slash command or a menu.
→ Class 5: Implementing Prompts
Further Reading
- MCP specification: Resources: the normative rules behind this whole class, covering the capability, the protocol messages, templates, annotations, the error codes and the security considerations.
- MCP specification: Lifecycle: the three-message handshake typed out in the terminal section, including the two SHOULD NOT rules on either side of the initialized notification.
- MCP specification: Transports: the stdio rules the terminal session relies on, one JSON-RPC message per line with newlines only as separators, protocol traffic on stdout and logging on stderr.
- MCP specification: Subscriptions: what replaces
resources/subscribein the current revision, with thesubscriptions/listenstream, the filter, acknowledgement and cancellation. - MCP specification: Completion: how a host can suggest values for a template variable as a person types.
- MCP Java SDK: MCP Server: the SDK's own resource, binary-resource, subscription and template examples, in the same sync and async pair this class uses.
- RFC 6570: URI Template: the template syntax the specification points at, including the operators the SDK's default parser leaves out.
- Connect Claude Code to tools via MCP: a documented client that does reach resources, listing them in the
@autocomplete and reading them by URI.
Sources
- MCP specification: Server Overview: that Tools are model-controlled, Resources application-controlled and Prompts user-controlled.
- MCP specification: Resources, User Interaction Model: the three ways a host may surface resources, quoted in the design section.
- MCP specification: Resources, Error Handling: that
-32002means resource not found and-32603an internal error, and the shape of the error body with the URI indata. - MCP specification: Resources, Annotations: that
audiencetakesuserandassistant, thatpriorityruns from 0.0 to 1.0 with 1 meaning most important, and that annotations are hints a client may use. - MCP specification: Resources, Capabilities: that
subscribeandlistChangedare the two optional resource features, which is what.resources(true, true)declares. - MCP specification: Resources, Security Considerations: the three rules quoted in the warning after the template, on validating URIs, access controls and permission checks.
- MCP specification: Lifecycle: the two SHOULD NOT rules quoted around
notifications/initialized. - MCP specification: Subscriptions: that a client names the URIs it wants in a
resourceSubscriptionsfilter and that the server may only send the notification types the client listed. - MCP Java SDK: MCP Server: that only sessions that explicitly subscribed to a URI receive
notifications/resources/updated. - DefaultMcpUriTemplateManager, MCP Java SDK source: that each
{name}becomes a capture group that stops at a slash, and thatextractVariableValuesreturns an empty map when the URI does not match. - McpServerSession, MCP Java SDK source: that a request sent before
notifications/initializedis held rather than rejected, and the wording of the Unexpected response warning. - StdioServerTransportProvider, MCP Java SDK source: that a blank line is logged by this class as Error processing inbound message and breaks the inbound read loop.
- Map, Java SE 21 API documentation: that the iteration order of the
Map.offactory methods is unspecified and subject to change, which is why the profile uses aLinkedHashMap. - PostgreSQL, archived MCP reference server: that it exposes
postgres://<host>/<table>/schemaas a resource of column names and types andqueryas a read-only SQL tool, and that it is archived and unmaintained. - Filesystem, MCP reference server: that the official filesystem server exposes its file operations as tools and does not declare any resources.
- Git, MCP reference server: that the official git server exposes history as tools and does not declare any resources.