Skip to main content

Class 8: Consuming Resources and Prompts

Duration: ~30 minutes | Level: Intermediate | Prerequisites: Class 7: Handing the Tools to a Model.


What We'll Cover

  • Why defaultTools(mcpTools) covers tools and nothing else
  • Reading a resource and putting it in front of the model
  • Filling a URI template from the client
  • Running the refund-email prompt, and turning its messages into a request
  • Asking the server for completions

The Bridge Only Carries Tools

Class 7 gave the model everything with one line:

.defaultTools(mcpTools)

SyncMcpToolCallbackProvider turns discovered MCP tools into Spring AI tool callbacks, and ChatClient knows what to do with those. There is no matching provider for resources or prompts, and there is a good reason for that: a tool callback exists so the model can invoke something, and neither resources nor prompts work that way. A resource is attached by the application, a prompt is chosen by a person. Neither is a decision the model makes, so neither belongs in the list of things the model may call.

That means we use McpSyncClient directly, which is the same client Class 6 printed the tool list with.


Reading a Resource

Create support-agent/src/main/java/com/themcpguy/supportdesk/agent/McpResources.java:

package com.themcpguy.supportdesk.agent;

import java.util.List;

import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest;
import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;

import org.springframework.stereotype.Component;

@Component
public class McpResources {

private final McpSyncClient orders;

McpResources(List<McpSyncClient> clients) {
this.orders = clients.getFirst();
}

public String read(String uri) {
return orders.readResource(new ReadResourceRequest(uri))
.contents()
.stream()
.filter(TextResourceContents.class::isInstance)
.map(TextResourceContents.class::cast)
.map(TextResourceContents::text)
.findFirst()
.orElseThrow(() -> new IllegalStateException("No text contents at " + uri));
}
}

contents() is a list because one URI may return several pieces, and each is either TextResourceContents or BlobResourceContents. The filter above keeps the text and would skip a binary attachment.

Picking clients.getFirst() works while there is one connection. Class 10 adds more, and this becomes a lookup by server name.


Putting It in the Conversation

The returns policy is needed by nearly every support conversation. Rather than hope the model asks, attach it:

public String chatWithPolicy(String conversationId, String userMessage) {
String returnsPolicy = resources.read("policy://returns");

return chatClient.prompt()
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.system(system -> system.text("""
{base}

The current returns policy is below. Use it for any question about
returns, refunds or the returns window. Do not rely on anything you
remember about returns policies.

---
{policy}
""").param("base", BASE_SYSTEM).param("policy", returnsPolicy))
.user(userMessage)
.call()
.content();
}

Now the question from Class 1 works end to end:

Where is order ORD-10001, and can the customer still return it?

The order comes from a tool the model chose to call. The returns window comes from a resource our code attached before the model saw the question. That split is the point of the two primitives, and this is the first place in the course where both are answering one question.

The cost of attaching

A resource in the system prompt is sent on every request in that conversation, so it is paid for every turn. A tool is paid for only when the model calls it, but costs a round trip when it does.

The rule of thumb: attach what almost every conversation needs and what is small. Leave as a tool what is large, or needed rarely, or specific to one question. The returns policy is a page of text and relevant constantly, so it goes in. Two hundred orders do not.


Filling a Template

order://{orderId} is a template, so the client has to fill it in. Templates are listed separately from fixed resources:

orders.listResourceTemplates().resourceTemplates()
.forEach(t -> System.out.println(t.uriTemplate()));
order://{orderId}

Reading one means substituting the value yourself:

public String orderResource(String orderId) {
return read("order://" + orderId);
}

MCP does not send the template to the server; it sends the finished URI, and the server matches it back to the template. That is why Class 4's method parameter had to be named orderId.

This is what makes the UI case work. Someone looking at ORD-10001 in the browser opens a conversation with that order already attached, and their first question does not need to name it.


Running the Prompt

draft_refund_email lives on the server so its wording is maintained in one place. Fetching it gives back messages, not a string:

package com.themcpguy.supportdesk.agent;

import java.util.List;
import java.util.Map;

import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.stereotype.Service;

@Service
public class RefundEmailService {

private final McpSyncClient orders;
private final ChatClient chatClient;

RefundEmailService(List<McpSyncClient> clients, ChatClient.Builder builder) {
this.orders = clients.getFirst();
this.chatClient = builder.build();
}

public String draft(String orderId, String reason) {
var result = orders.getPrompt(new GetPromptRequest(
"draft_refund_email",
Map.of("orderId", orderId, "reason", reason)));

List<Message> messages = result.messages().stream()
.map(RefundEmailService::toSpringAiMessage)
.toList();

return chatClient.prompt()
.messages(messages)
.call()
.content();
}

private static Message toSpringAiMessage(
io.modelcontextprotocol.spec.McpSchema.PromptMessage promptMessage) {

String text = promptMessage.content() instanceof TextContent textContent
? textContent.text()
: promptMessage.content().toString();

return promptMessage.role() == Role.USER
? new UserMessage(text)
: new AssistantMessage(text);
}
}

The conversion is the only fiddly part. MCP has its own PromptMessage with an MCP Role; Spring AI has UserMessage and AssistantMessage. Nothing maps them for us, so the method above does it.

This is where Class 5's decision pays off. Because the server returned Role.USER, this becomes a UserMessage and the model treats it as an instruction. Had the server returned a bare String, Spring AI would have made it ASSISTANT, this method would produce an AssistantMessage, and the model would see an email it had apparently already written.

Note the plain builder.build() with no tools. Drafting an email from a prompt that already contains the order details needs no tool calls, and leaving the tools out means the model cannot decide to go looking for more.

System.out.println(refundEmails.draft("ORD-10001", "arrived damaged"));
Dear Ana Ruiz,

I'm sorry your order arrived damaged. We've refunded 179.99 for order
ORD-10001. The money should reach your account within three to five working
days.

Kind regards,
Support

The customer's name and the amount are in there because the server put them there in Class 5. The agent passed two arguments and did not look anything up.


Asking for Completions

The completion built in Class 5 is available to the client too:

import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
import io.modelcontextprotocol.spec.McpSchema.PromptReference;

public List<String> completeOrderId(String prefix) {
return orders.completeCompletion(new CompleteRequest(
new PromptReference("draft_refund_email"),
new CompleteRequest.CompleteArgument("orderId", prefix)))
.completion()
.values();
}
completeOrderId("ORD-100")   // [ORD-10001, ORD-10002, ORD-10003, ORD-10004]

No model is involved. This is a lookup on the server that owns the data, which is why the agent does not need its own copy of the order IDs to offer them.



Check It

Start order-service, then the agent with the command-line profile:

You run this, in one terminal
mvn -pl order-service spring-boot:run
You run this, in another
mvn -pl support-agent spring-boot:run -Dspring-boot.run.profiles=cli

Ask the question from Class 1, the one that needs both a tool and a resource:

Where is order ORD-10001, and can the customer still return it?
Agent: Order ORD-10001 is shipped via DHL (tracking number DHL-88213) and
expected to be delivered by 2026-05-14.

The return window for GOLD-tier customers is 60 days from delivery. Since the
delivery is expected on 2026-05-14, the return period will end in mid-July. The
customer can return the item unused and in its original packaging within this
window. If the item arrived damaged, they should report it within 14 days of
delivery for a replacement or refund.

That answer needed both halves and could not have come from either alone. The shipment and the customer's tier came from get_order, which the model chose to call. The 60-day window came from policy://returns, which our code attached before the model saw the question. It is 60 rather than 30 because the policy gives Gold-tier customers longer and get_order said Ana Ruiz is Gold.

To prove the policy is doing the work rather than the model's own knowledge, edit order-service/src/main/resources/policies/returns.md, change the Gold window to 90 days, restart order-service and ask again. The answer follows the file.

The refund email needs no model call from you either:

You run this
curl -s -X POST http://localhost:8081/api/refund-email \
-H 'Content-Type: application/json' \
-d '{"orderId":"ORD-10001","reason":"arrived damaged"}'

What We Built

support-agent now uses all three primitives. The model chooses tools; our code attaches resources and runs prompts. The question that opened Class 1 is answered by a tool and a resource together.

Everything so far has talked to one server, and that server is ours. Class 9 connects one we did not write.


Next: Class 9: A Server We Did Not Write. The filesystem server over stdio, and Claude Desktop's own configuration file.