Class 8: Consuming Resources and Prompts
Duration: ~65 minutes | Level: Intermediate | Prerequisites: Class 7: Handing the Tools to a Model. The model set up in Class 7 is needed again.
What We'll Cover
- Why resources and prompts need
McpSyncClientinstead of adefaultToolsline - Reading a resource and putting it in front of the model
- Filling a URI template, and attaching the order open in the frontend
- Running the refund-email prompt, and turning its messages into a request
- Why our client does not call completion, and which kind of client does
This class carries on from Class 7. If you followed along, keep working in the project you
already have. If you skipped it, clone the class_7
branch to start from the same place:
git clone --branch class_7 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
Why This Class Uses McpSyncClient Directly
In Class 7 we put the discovered tools in front of the model with one line:
.defaultTools(mcpTools)
This class does the same kind of work for resources and prompts, and each of them takes more than a single line. The reason lies in how the three primitives (tools, resources and prompts) differ from one another.
mcpTools in that line is the SyncMcpToolCallbackProvider bean, injected into the constructor of SupportAgentService in Class 7. Spring AI creates the bean from the MCP connections in application.yaml, and its job is conversion: it turns every tool discovered on every connection into a ToolCallback, the form ChatClient understands and can offer to the model. One line was enough in Class 7 because Spring AI had already done that conversion.
Spring AI does not provide an equivalent bean for resources or prompts, and that is not a missing feature. The three primitives differ in who decides that they run, and Spring AI's client side follows that difference:
| Primitive | Who decides it runs | Spring AI client-side bean | The call in this class |
|---|---|---|---|
| Tool | the model, in the middle of a conversation | SyncMcpToolCallbackProvider, which turns each discovered tool into a ToolCallback | .defaultTools(mcpTools), from Class 7 |
| Resource | our application code | none | readResource(ReadResourceRequest.builder(uri).build()) |
| Prompt | the person at the browser | none | getPrompt(GetPromptRequest.builder(name).arguments(...).build()) |
The second column explains the third. A ToolCallback exists so that the model can invoke something in the middle of a conversation. Resources and prompts are not invoked by the model: Class 4 established that the application decides when a resource is attached, and Class 5 that a person decides when a prompt runs. Because our own code makes both calls, they stay outside ChatClient entirely.
So this class talks to the server through McpSyncClient directly, the same client we used in Class 6 to print the tool list. Our code asks the server for a resource or a prompt at the moment it decides one is needed.
Reading a Resource
Create support-agent/src/main/java/com/themcpguy/supportdesk/agent/mcp/McpResources.java:
package com.themcpguy.supportdesk.agent.mcp;
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(ReadResourceRequest.builder(uri).build())
.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. In Class 9 we add a second connection, to a filesystem server somebody else wrote, and this becomes a lookup by server name.
Putting a Resource in the Conversation
The returns policy is a resource, and Class 4 explained what that means: MCP does not give the model any way to request a resource, so our code decides when it goes in front of the model. There is a reasonable alternative to attaching it, so the decision deserves a justification.
The alternative is a tool. Nothing stops order-service from exposing the policy the way we exposed a lookup in Class 3:
@McpTool(
name = "get_returns_policy",
description = """
The shop's returns policy, including the returns window.
""")
public String getReturnsPolicy() {
return read("policies/returns.md");
}
The model would then fetch the policy when it decides the user is asking about returns, at the cost of one extra round trip. Most support conversations are not about returns, and this is where cost comes in. A model provider charges per token it receives, as Class 7 showed, and everything in a request counts towards that: the question, the conversation so far, the tool descriptions, and the system prompt. Attaching the policy adds its whole page of text to the system prompt, so every question in every conversation pays for those extra tokens.
Our code could try to attach on demand instead: matching on words like "return", or retrieving by similarity the way Spring AI's QuestionAnswerAdvisor does. Both guess at the subject of a question from its words alone, where the model reads the whole conversation. The two designs differ on more than cost:
| Attached by our code | Behind a get_returns_policy tool | |
|---|---|---|
| Who decides it is used | our code, on every question | the model, when it judges the question to be about returns |
| When the text travels | inside the system prompt of every request | only after the model asks for it |
| If the model believes it already knows the window | it still reads our policy, which is in front of it | it can answer with 30 days from its training data, without calling the tool |
| A conversation that never mentions returns | pays for the policy's page of text | pays for the tool's name, description and schema |
We attach it, for two reasons. The first is that the policy is one page of text, so carrying it costs little. The second is the third row of that table: a returns window looks like general knowledge, so a model can answer from its training data and state a policy that is not ours. Attaching the policy takes that decision away from the model.
So the agent reads the resource at the start of each exchange and places its text into the system prompt. From the model's point of view the policy is part of its instructions; it does not know a resource was involved. One question from the browser travels like this:
The second arrow is new since Class 7. Our code fetches the policy itself, before the model is called at all, so the policy arrives with the question.
Two changes to SupportAgentService from Class 7 prepare for that. First, move the system text out of defaultSystem(...) into a constant, so that more than one method can use it:
static final String BASE_SYSTEM = """
You are a support agent for an online shop. You answer questions
about orders using the tools you have been given.
Guidelines:
- Use a tool to find out anything about an order. Never guess an
order ID, a status, a total or a delivery date.
- Order IDs look like ORD-10001 and customer IDs like CUST-42. If
the user gives you something that is not in that form, ask.
- Amounts are in euros. Quote them with two decimal places
and the euro sign.
- If a tool returns an error, tell the user what it said and what
they could try instead.
- Keep answers to a few sentences unless asked for detail.
""";
Second, give the class a McpResources field, so the policy can be read when a conversation starts. The import is com.themcpguy.supportdesk.agent.mcp.McpResources:
private final ChatClient chatClient;
private final McpResources resources;
SupportAgentService(ChatClient.Builder builder,
SyncMcpToolCallbackProvider mcpTools,
McpResources resources) {
this.resources = resources;
this.chatClient = builder
.defaultSystem(BASE_SYSTEM)
.defaultTools(mcpTools)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(
MessageWindowChatMemory.builder().build()).build())
.build();
}
Now add the method that attaches the policy, alongside chat:
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();
}
The policy is read fresh on every call, so an edit to the policy file on the server shows up in the very next answer. That is what we check at the end of this class.
Conversations in the frontend app from Class 7 are the real support conversations, the ones the policy exists for, so point its endpoint, /api/chat, at the new method in SupportController:
@PostMapping("/api/chat")
public ChatReply chat(@RequestBody ChatRequest request) {
return new ChatReply(agent.chatWithPolicy(request.conversationId(), request.message()));
}
SupportCli stays on the plain chat method. The console loop is our own tool for watching tool calls while we build, and its questions rarely concern returns, so console conversations run without the policy.
Testing the feature needs all three pieces running:
Starting the three pieces: the same commands as Class 7 (click to expand)
mvn -pl order-service spring-boot:run
Wait for Started OrderServiceApplication, then start the agent in another terminal. No cli profile this time, so it serves the browser:
mvn -pl support-agent spring-boot:run
cd frontend
npm run dev
npm install already ran in Class 7, so npm run dev is enough. The frontend is on http://localhost:5173.
Open http://localhost:5173 and ask the first question in this course that needs a tool and a resource at once:
Where is order ORD-10001, and can the customer still return it?
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.
As in Class 7, your wording will differ from run to run; the facts come from the data and should not. The shipment and the tracking number came from get_order, a tool the model chose to call. The 60-day window came from policy://returns, the resource our code attached before the model saw the question. The file behind that URI, order-service/src/main/resources/policies/returns.md, sets three windows:
| Case | Window | Counted from |
|---|---|---|
| Most items | 30 days | the delivery date |
| Gold-tier customer | 60 days | the delivery date |
| Item arrived damaged, reported for replacement or refund | 14 days | the delivery date |
The answer used the second row, because get_order reported Ana Ruiz as Gold, and it took the 14-day line from the same file.
The cost of attaching
The two diagrams below show the same returns question answered both ways, and the second one is what "a round trip" means.
With the policy attached, answering takes one call to the model. The policy's page of text rides along inside the system prompt, so its tokens are paid for on this question and on every other question in the conversation, whether or not the answer needs them:
With the policy behind a hypothetical get_returns_policy tool, nothing is sent up front. The model reads the question, decides it needs the policy, and asks for it. The model cannot fetch anything itself, so our agent makes the MCP call and then calls the model a second time, with the policy included. That second call is the round trip:
Two calls to the model instead of one, and the person waits for both, so the answer takes roughly twice as long to arrive. The second request is not small either. The model does not keep anything from one request to the next, as Class 7 explained, so that request has to carry everything the first one carried, plus the tool call and the policy.
That is the whole trade-off:
- Attached: every question pays the policy's tokens, and no question waits for an extra call.
- Tool: only returns questions pay, and each pays more: a second model call, an MCP call in between, and the wait for both.
A tool is not entirely free on the other questions either: its name, description and schema travel with every request, as Class 7 showed. But a tool definition is a few lines, where the policy is a page.
As a guide: attach what is small, comes up often, or must not be left to the model's memory. Leave as a tool what is large, needed rarely, or specific to one question. The returns policy is a page of text and the model must answer from our policy and not its own, so we attach it. The order data is much larger, and a conversation usually concerns a single order, so orders stay behind the get_order tool and the order:// template.
Filling a Template, and Attaching the Order
order://{orderId} from Class 4 is a template, and this section puts it to work on the case Class 4 promised. Someone looking at an order in the frontend opens a conversation about it, and their first question does not need to name that order. Three pieces get us there: seeing the template from the client, a method that fills it in, and one field the frontend has been sending since Class 7 that our code has ignored so far.
Templates are listed separately from fixed resources, which is why Class 6's inspector output did not show this one. Add a template line to McpInspector, inside the same if block that prints the resources:
client.listResourceTemplates().resourceTemplates()
.forEach(template -> System.out.printf(" template %s%n", template.uriTemplate()));
That call reads the first page of the list. order-service has one template, so one page holds everything. A server with more would send back a nextCursor, and the client passes that cursor to the next call to get the following page, which is how pagination works for every list request in MCP.
Restarting the agent now prints one more line in the inspector's order-service section:
resource policy://shipping
resource policy://returns
template order://{orderId}
Reading through a template means the client fills it in itself and sends the finished URI. Add the method to McpResources:
public String orderResource(String orderId) {
return read("order://" + orderId);
}
MCP does not send the template to the server; it sends order://ORD-10001, and the server matches it back to the template and calls Class 4's annotated method with the orderId parameter filled in. That is why Class 4's method parameter had to be named orderId.
Attaching the order the person is looking at
Class 7 mentioned, in passing, that the chat panel posts three fields: a conversationId, a message, and an orderId, which is the order open in the panel, or null when the chat was opened without one. ChatRequest declared only the first two, so the third was dropped on deserialisation. Declare it, in SupportController:
public record ChatRequest(String conversationId, String message, String orderId) {}
and pass it through:
@PostMapping("/api/chat")
public ChatReply chat(@RequestBody ChatRequest request) {
return new ChatReply(agent.chatWithPolicy(
request.conversationId(), request.message(), request.orderId()));
}
The app does not have a login yet, so this orderId arrives from the browser unchecked, and any caller of /api/chat can name any order. Class 17 covers authorization, where a client obtains an OAuth 2.1 access token and sends it with every request.
Then give chatWithPolicy the third parameter, and a second attachment that is empty when no order was sent:
public String chatWithPolicy(String conversationId, String userMessage, String orderId) {
String returnsPolicy = resources.read("policy://returns");
String orderBlock = orderId == null || orderId.isBlank() ? "" : """
The conversation was opened from the order below. Questions about "this
order" or "the customer" refer to it.
""" + resources.orderResource(orderId);
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}
{order}
""")
.param("base", BASE_SYSTEM)
.param("policy", returnsPolicy)
.param("order", orderBlock))
.user(userMessage)
.call()
.content();
}
There is a rule to follow whenever we build a prompt this way: write only our own fixed wording in the template text, and pass everything fetched at runtime through .param(...). The text given to .text(...) is a template, rendered by StringTemplate, which treats everything between { and } as the name of a parameter to fill in. That is how {base}, {policy} and {order} are replaced. Fetched content can carry braces of its own, and an order serialised as JSON is full of them: {"orderId": "ORD-10001", ...}. Each way of putting that JSON into the prompt ends differently:
concatenated into the template text:
.text(BASE_SYSTEM + "\n" + orderJson)
ERROR o.s.a.t.st.StTemplateRenderer : 2:12: '"ORD-10001"' came as a complete surprise to me
java.lang.IllegalArgumentException: The template string is not valid.
passed as a parameter value, which is what the method above does:
.text("...{order}...").param("order", orderJson)
renders as {"orderId": "ORD-10001", "status": "SHIPPED"}
a placeholder left without a matching parameter:
.text("...{order}...")
java.lang.IllegalStateException: Not all variables were replaced in the
template. Missing variable names are: [order].
The first case fails before any parameter is filled in, because StringTemplate cannot parse the text at all. Spring AI does not read a parameter value as template text: it inserts the value as it is, braces and all. That is why both the policy and the order go through .param(...) in the method above.
Spring AI's documentation offers a second answer for a prompt that has to contain JSON: change the delimiters, by passing StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build() to .templateRenderer(...). We do not need that here, because only our own fixed wording goes into the template text. The person's message is safe too: Spring AI renders a text only when parameters were given for it, and .user(userMessage) does not give any.
An application knows things about the current session that the model would otherwise have to ask for or guess:
- who is logged in, with their name, language and customer tier
- which page, or which order, is open
- which customer the conversation belongs to
Whenever that state would help the model answer, our code can attach it the same way: chosen by the application, and placed in the context before the question.
Attached text lands in the system prompt, where the model reads it as part of its instructions. That is safe here because we wrote order-service ourselves. Class 16 shows what happens when text from a server we did not write carries instructions of its own, which OWASP lists as LLM01, prompt injection.
Try it
Restart the agent, keep the other two pieces running, and open ORD-10001 in the frontend before asking, so the panel sends its ID with the message. Then ask without naming the order:
Can the customer still return this order?
The answer should name the customer, the delivery estimate and the 60-day window, even though the question left the order and the customer unnamed. The frontend sent ORD-10001 alongside the message, and our code attached that order before the model saw the question. The Class 7 log shows something here too: the model no longer has to call get_order, because the order is already in the request. Whether it calls it anyway is its own choice, as Class 7 showed when the model re-fetched an order it already had.
Running the Prompt
One primitive is left. In Class 5 we implemented a prompt in order-service, draft_refund_email. Given an order ID and a reason, the server builds the instruction for a refund email, with the customer's name and the order total filled in from its database. Class 5 also established that a prompt is run by a person, not by the model. The client side of that is two pieces: a service that fetches the prompt and sends it to the model, and an endpoint behind the frontend's Draft refund email button, which is how the support person triggers it.
Fetching a prompt, the prompts/get request we sent with curl in Class 5, returns a list of messages, not a block of text. A message is a role, who is speaking, plus content, what was said. This is the one from Class 5's reply, trimmed:
{
"role": "user",
"content": {
"type": "text",
"text": "Write a short email to Ana Ruiz confirming a refund for order ORD-10001. ..."
}
}
A conversation with a model is built from the same pieces, which is what every request since Class 7 has been sending. So running a prompt is only two steps: fetch its messages with the arguments filled in, and send them to the model as the start of a new conversation. One click on the button travels like this:
The fourth arrow is where the two libraries meet: MCP's PromptMessage becomes Spring AI's UserMessage, and the text inside it travels unchanged.
Create support-agent/src/main/java/com/themcpguy/supportdesk/agent/service/RefundEmailService.java:
package com.themcpguy.supportdesk.agent.service;
import java.util.List;
import java.util.Map;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
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(GetPromptRequest.builder("draft_refund_email")
.arguments(Map.of("orderId", orderId, "reason", reason))
.build());
List<Message> messages = result.messages().stream()
.map(RefundEmailService::toSpringAiMessage)
.toList();
return chatClient.prompt()
.messages(messages)
.call()
.content();
}
private static Message toSpringAiMessage(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 two libraries have different Java types for a message: MCP delivers PromptMessage with an MCP Role, and Spring AI's ChatClient takes UserMessage and AssistantMessage. The same message looks like this in each:
// what arrives from the MCP client:
new PromptMessage(Role.USER, TextContent.builder("Write a short email to Ana Ruiz ...").build())
// what ChatClient accepts:
new UserMessage("Write a short email to Ana Ruiz ...")
Nothing converts between them for us, so toSpringAiMessage does it: it reads the text out of the content, and picks the Spring AI class that matches the role.
In Class 5 we returned Role.USER from the prompt deliberately, and this method is where that matters: the message becomes a UserMessage, which the model treats as an instruction. If the server had returned a bare String, Spring AI would have made it ASSISTANT, this method would produce an AssistantMessage, and the model would treat the email as something it had already written.
Drafting the email does not need any tool calls, because the prompt already contains the order details. Leaving the tools out of builder.build() also means the model cannot decide to go looking for more.
An Endpoint to Run It
The person who runs this prompt is the support person at the browser, and the selected order's detail panel already has a Draft refund email button. Until the endpoint behind it exists, the button answers with an error naming this class, the same arrangement as the chat panel before Class 7.
To call the service over HTTP, add a method to SupportController from Class 7, with a record for the request body, and inject the service next to the agent. RefundEmailService is in the same package as SupportAgentService, so the import follows the same pattern:
private final SupportAgentService agent;
private final RefundEmailService refundEmails;
SupportController(SupportAgentService agent, RefundEmailService refundEmails) {
this.agent = agent;
this.refundEmails = refundEmails;
}
public record RefundRequest(String orderId, String reason) {}
@PostMapping("/api/refund-email")
public ChatReply refundEmail(@RequestBody RefundRequest request) {
return new ChatReply(refundEmails.draft(request.orderId(), request.reason()));
}
Restart the agent, keep order-service and the frontend running, and select ORD-10001. Its detail panel shows the button; click it, and pick arrived damaged as the reason, or write one of your own. The five quick choices in the dialog are the same five reasons completeRefundArgument offers in Class 5. After a few seconds the draft appears, ready to read and copy.
Two details of the button are business decisions. The first is which orders it works on:
| Order status | Draft refund email button | What the support person does instead |
|---|---|---|
| PENDING | disabled | cancel the order, which is what starts a refund |
| PROCESSING | disabled | cancel the order, which is what starts a refund |
| SHIPPED | enabled | |
| DELIVERED | enabled | |
| CANCELLED | enabled |
Nothing has shipped on a PENDING or a PROCESSING order, so cancelling is what issues the refund, and the note under the button says to cancel instead. The second decision is a simplification: nothing checks that a refund was actually issued before the email confirming it is drafted. A real desk would draft this after the refund action; here the draft stands in for that step.
The button makes a POST request to /api/refund-email, and we can make the same request with curl, which lets us read the exact JSON that goes in and the reply that comes back:
curl -s -X POST http://localhost:8081/api/refund-email \
-H 'Content-Type: application/json' \
-d '{"orderId":"ORD-10001","reason":"arrived damaged"}'
On Windows: the same call in PowerShell (click to expand)
The call needs the same three changes as the curl commands in Classes 2 to 5. Use curl.exe instead of curl, because Windows PowerShell treats curl as an alias for Invoke-WebRequest. Use backticks for the line continuations. Save the JSON body to a file, because Windows PowerShell 5.1 rebuilds an inline body and the inner double quotes do not survive:
@'
{"orderId":"ORD-10001","reason":"arrived damaged"}
'@ | Set-Content -Path body.json
curl.exe -s -X POST http://localhost:8081/api/refund-email `
-H "Content-Type: application/json" `
-d "@body.json"
The reply is JSON with a single reply field, printed on one line. The email inside it reads like this:
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 the email because the server put them into the prompt messages, as we built in Class 5. The agent only passed the order ID and the reason.
Why Our Client Does Not Use Completion
In Class 5 we built completion for both of draft_refund_email's arguments, and in this class we have not called it.
Our own client does not need completion. The dialog needs the five refund reasons, and we know our own server's data, so we put the reasons straight into the frontend as quick choices. This section explains that choice and leaves the code unchanged.
Completion is for clients that were not written for our server. A host is the application a person uses, with an MCP client inside it that speaks the protocol; a generic host is one meeting order-service for the first time. Such a host discovers draft_refund_email, learns its arguments from the prompt definition, and puts a form in front of the person. The host cannot know which refund reasons our shop uses, and completion/complete is the standard way to ask:
The orderId field answers through the same request, doing the different job Class 5 described. The person is not browsing for an order: they already have the ID from the ticket in front of them. The suggestions keep the field to IDs that exist, so a typo is caught while typing instead of failing the prompt afterwards.
No model is involved, and the host does not hold any code written for our shop: the prompt definition described the form, and completion filled its suggestions from the server that owns the data. Class 5's curl calls showed these requests on the wire. In Class 17 we connect order-service to a client of exactly this kind.
Testing That the Answer Follows the Policy File
Every feature in this class was tested as it was built, so one test remains. It proves that the returns window in the answers comes from the attached file, and not from something the model already knew.
With everything still running, edit order-service/src/main/resources/policies/returns.md and change the Gold window to 90 days. Restart order-service, and ask again in the frontend:
Where is order ORD-10001, and can the customer still return it?
The answer now says 90 days, which can only have come from the edited file, because nothing else changed. Then change the window back to 60 days: the rest of the course's answers assume it, and so does the companion repository. That is the guarantee from Putting a Resource in the Conversation: the policy is read fresh on every call, and the model answers from what it is given.
What We Built
support-agent now uses all three primitives. The model chooses tools; our code attaches resources and runs prompts. "Where is order ORD-10001, and can the customer still return it?" is answered by a tool and a resource together, and a conversation opened from an order carries that order as a second attached resource, read through the order://{orderId} template.
So far the agent has talked only to order-service, which we wrote ourselves. In Class 9 we connect the agent to a filesystem server somebody else published.
Next: Class 9: A Server We Did Not Write. The filesystem server over stdio, and Claude Desktop's own configuration file.
Further Reading
- Resources: the rules behind this class, including why resources are application-driven and how
resources/templates/listdiffers fromresources/list. - Prompts: getting a prompt: the
prompts/getrequest and reply, with the message list, the role field and every content type a message can carry. - Completion: the request our client chose not to send, with the
ref/promptandref/resourcereference types and the 100-item cap on a suggestion list. - Pagination: the cursor loop behind the list calls in
McpInspector, and the four operations that use it. - MCP Client, MCP Java SDK: the same
readResourceandgetPromptbuilder calls this class writes, plus the client's default request timeout. - Prompts, Spring AI: the Spring AI message roles that
toSpringAiMessagemaps MCP'sRoleonto, and what a model does with each. - RFC 6570, URI Template: the syntax behind
order://{orderId}, and the expansion rules a client follows when it fills a template in. - LLM01:2025 Prompt Injection, OWASP: why text fetched from a server and placed in a system prompt deserves care, which is the risk Class 16 returns to for tool results.
Sources
- Resources: that resources are application-driven, with the host application deciding how context is incorporated, that
resources/templates/listis a separate call whose entries carryuriTemplate, and thatresources/readtakes a finished URI. - Prompts: that
prompts/getreturns a list of messages, each a role plus content. - Completion: that
completion/completeis the request a host sends to fill in a prompt argument, using aref/promptreference. - Pagination: that
resources/templates/listis paginated, solistResourceTemplates()reads one page. - MCP Client, MCP Java SDK: the
readResourceandgetPromptsignatures used inMcpResourcesandRefundEmailService, and thenextCursoron a list result. - Chat Client API, Spring AI: that
.text(...)is rendered with StringTemplate, and that the delimiters can be changed for a prompt containing JSON. - MCP Server Annotations, Spring AI: that an
@McpPromptmethod builds eachPromptMessagewith an explicitRole, which is the choice Class 5 made. - MCP Client Boot Starter, Spring AI: that the starter builds the MCP client connections from
application.yamland auto-configures theSyncMcpToolCallbackProviderbean behind.defaultTools(mcpTools).