Skip to main content

Class 4: Implementing Resources

Duration: ~85 minutes | Level: Intermediate | Prerequisites: Class 3: Implementing Tools

Companion code

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:

PrimitiveWho controls it
ToolsModel
ResourcesApplication
PromptsUser

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.

You attach resources; Claude does not fetch them

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:

URIKindReturns
customers://directorystatic resourceevery customer, as JSON
customers://{ customerId }templateone customer and its contacts
customers://{ customerId }/badge.pngbinary templatea 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 callField the client seesRequired by the protocolWho reads it
Resource.builder(uri, ...)uriyesthe client, as the address to read
Resource.builder(..., name)nameyesthe host's picker
.description(...)descriptionnoa person choosing from a list
.mimeType("application/json")mimeTypenothe host, deciding how to render it
TextResourceContents.builder(uri, ...)contents[].uriyesthe client, matching the reply to the request
TextResourceContents.builder(..., text)contents[].textyeswhatever consumes the data
.mimeType(...) on the contentscontents[].mimeTypenothe 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 throwscodemessagedataWhat the caller concludes
McpError.RESOURCE_NOT_FOUND.apply(uri)-32002Resource not found{"uri":"customers://NOPE"}I asked for something that does not exist
McpError.builder(code).message(...).data(...).build()whatever you passyoursyourswhatever you chose to say
any other exception, say NoSuchElementException-32603the exception messagethe exception class and its messagethe 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.

Let the SDK parse the template

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.

Validate the URI, and check who is asking

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 resourceBinary resource
Contents typeTextResourceContentsBlobResourceContents
Builder.builder(uri, text).builder(uri, base64)
Field on the wiretextblob
What goes in itthe string as writtenBase64.getEncoder().encodeToString(bytes)
mimeType hereapplication/jsonimage/png
Size on the wirethe textabout 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:

  1. Match the sender's domain to CUST-2. That is your business logic, and it is not a hard problem.
  2. Call resources/read on customers://CUST-2.
  3. Put that JSON into the conversation.
  4. 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.

ToolResource
Is the data definitely there?Only if the model chose to fetch itAlways, because your code put it there
Round trips before answeringModel, tool, model againNone
Same context on every ticket?No, it depends what it decidedYes
What you can record afterwardsa call and its argumentsa 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:

PrimitiveWhat it isWhy that side
Resourcepostgres://<host>/<table>/schema, the column names and types of one tableneeded on every question, and never varies with the question
Toolquery, which runs a read-only SQL statementonly 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 builtWho reads it in the support desk
customers://directorythe app's customer picker, and Claude Desktop's + menu
customers://{customerId}the app, once per ticket, after it resolves the sender
the subscriptionthe 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.

What this costs you

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.

HostHow it reaches your resources
Claude Desktopthe + 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.

Only paste the blocks marked "You send this"

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.

Your logs are in the way, and that is correct

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.

You send this
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}
The server replies
{"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.

You send this
{"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 initialize request.
  • The server SHOULD NOT send requests other than pings and logging before receiving the initialized notification.

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.

Do not press Enter again here

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.

You send this
{"jsonrpc":"2.0","id":2,"method":"resources/list","params":{}}
The server replies
{"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.

You send this
{"jsonrpc":"2.0","id":3,"method":"resources/templates/list","params":{}}
The server replies
{"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:

You send this
{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"customers://CUST-2"}}
The server replies
{"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:

You send this
{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"customers://CUST-2/badge.png"}}
The server replies
{"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.

You send this
{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"customers://NOPE"}}
The server replies
{"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 failsResource read that fails
Where it sits in the messageinside resultin error, beside the id
What marks it"isError": truea JSON-RPC code
Who is meant to see itthe modelthe client application
What they do with itreason about it and try something elsedecide what to show the user
How your handler signals itreturn a CallToolResult with the flag setthrow 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:

You send this
{"jsonrpc":"2.0","id":7,"method":"resources/subscribe","params":{"uri":"customers://CUST-2"}}
The server replies
{"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:

You send this
{"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:

The server replies
{"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:

You send this
{"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"customers://CUST-2"}}
The server replies
{"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.

Subscriptions changed in the 2026-07-28 spec

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 used2026-07-28, ratified
How you askresources/subscribe, one request per URIone subscriptions/listen request
What carries the URIsparams.urithe notifications.resourceSubscriptions array
How long it lastsuntil resources/unsubscribe or the session endsa long-lived stream
What comes back firstan empty resultnotifications/subscriptions/acknowledged
What the server may pushwhatever it choosesonly the notification types the client listed
Java SDK 2.0.0worksMethod 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/subscribe in the current revision, with the subscriptions/listen stream, 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