Class 5: Prompts and Completion
Duration: ~30 minutes | Level: Intermediate | Prerequisites: Class 4: Resources. Still no API key and no model.
What We'll Cover
- What a prompt is, and why it is neither a tool nor a resource
@McpPromptand@McpArg, and the roles a prompt method produces@McpComplete, so a client can offer order IDs while an argument is filled inprompts/list,prompts/getandcompletion/completeovercurl
The Third Primitive
A tool is chosen by the model. A resource is attached by the application. A prompt is chosen by the person.
It is a named, parameterised piece of conversation that a server offers and a client presents. In Claude Desktop these appear as commands the user picks from a menu. The server supplies the wording; the user supplies the arguments and decides when to run it.
The case for it in order-service is the refund email. Writing a good one means knowing the order, the customer's name, the returns window, and the tone the company uses. That knowledge belongs with the order system, not copied into every client that wants to send one. Exposing it as a prompt means the wording is maintained in one place, and improving it improves every client at once.
The Prompt
Create order-service/src/main/java/com/themcpguy/supportdesk/orders/mcp/RefundPrompts.java:
package com.themcpguy.supportdesk.orders.mcp;
import java.util.List;
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.springframework.ai.mcp.annotation.McpArg;
import org.springframework.ai.mcp.annotation.McpPrompt;
import org.springframework.stereotype.Component;
import com.themcpguy.supportdesk.orders.domain.Order;
import com.themcpguy.supportdesk.orders.service.OrderService;
@Component
public class RefundPrompts {
private final OrderService orderService;
RefundPrompts(OrderService orderService) {
this.orderService = orderService;
}
@McpPrompt(
name = "draft_refund_email",
title = "Draft a refund email",
description = "Write an email to a customer confirming a refund for one order.")
public List<PromptMessage> draftRefundEmail(
@McpArg(name = "orderId", description = "The order being refunded", required = true)
String orderId,
@McpArg(name = "reason", description = "Why the order is being refunded", required = true)
String reason) {
Order order = orderService.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("No order with ID " + orderId));
String instruction = """
Write a short email to %s confirming a refund for order %s.
Order total: %.2f
Reason for the refund: %s
Keep it to three sentences. Apologise once, state the amount, and say the
money takes three to five working days to arrive. Do not invent a
reference number or a date.
""".formatted(order.customer().name(), order.orderId(),
order.totalAmount(), reason);
return List.of(new PromptMessage(Role.USER, new TextContent(instruction)));
}
}
The method builds the instruction from real data. The customer's name and the order total come from the database, so the client asking for the prompt does not have to look them up first.
Return List<PromptMessage> rather than a String. A prompt method may return a String, and Spring AI will wrap it, but it wraps it as a message with Role.ASSISTANT. That is rarely what a prompt template means: the text is an instruction to the model, so it belongs in a USER message. Returning List<PromptMessage> sets the role explicitly.
The other accepted return types are PromptMessage, List<String>, and a full GetPromptResult when the description on the result needs setting too. Anything else fails with an exception naming the type.
Completion for the Argument
draft_refund_email takes an order ID. A person filling that in has no way to know which IDs exist, and typing ORD- and getting nothing is a poor experience.
@McpComplete supplies candidates for one argument of one prompt:
import org.springframework.ai.mcp.annotation.McpComplete;
@McpComplete(prompt = "draft_refund_email")
public List<String> completeOrderId(String prefix) {
return orderService.findIdsStartingWith(prefix, 20);
}
The prompt attribute names which prompt this completes. @McpComplete also has a uri attribute for completing a resource template's variables instead, and exactly one of the two is used.
The method may return a List<String>, a single String, a CompleteCompletion, or a full CompleteResult. With a List<String>, Spring AI reports the list as the values, its size as the total, and hasMore as false, so returning everything is honest only when the list really is complete. Capping at 20 as above means the count is 20 and hasMore still says false, which understates the truth. Returning a CompleteResult directly is the way to say there are more.
orderService.findIdsStartingWith is already in the starter, so there is nothing to add. It is ordinary Spring Data:
// Already in OrderService. Shown so you can see what the completion is built on.
public List<String> findIdsStartingWith(String prefix, int limit) {
return orderRepository
.findByOrderIdStartingWithOrderByOrderIdAsc(prefix.toUpperCase(), Limit.of(limit))
.stream()
.map(OrderEntity::getOrderId)
.toList();
}
Try It Over curl
Restart and open a session as in Class 2.
curl -N -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}'
{
"result": {
"prompts": [
{
"name": "draft_refund_email",
"title": "Draft a refund email",
"description": "Write an email to a customer confirming a refund for one order.",
"arguments": [
{ "name": "orderId", "description": "The order being refunded", "required": true },
{ "name": "reason", "description": "Why the order is being refunded", "required": true }
]
}
]
}
}
The arguments came from @McpArg, in the same way the tool input schema came from @McpToolParam.
Ask for the prompt with arguments filled in:
curl -N -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"draft_refund_email","arguments":{"orderId":"ORD-10001","reason":"arrived damaged"}}}'
{
"result": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Write a short email to Ana Ruiz confirming a refund for order ORD-10001.\n\nOrder total: 179.99\nReason for the refund: arrived damaged\n\nKeep it to three sentences. ..."
}
}
]
}
}
"role": "user" is there because we built the PromptMessage ourselves. Returning a String from the method would have produced "role": "assistant", which reads as though the model had already written the email.
Now the completion:
curl -N -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: PASTE-YOURS-HERE' \
-d '{"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"draft_refund_email"},"argument":{"name":"orderId","value":"ORD-100"}}}'
{
"result": {
"completion": {
"values": [ "ORD-10001", "ORD-10002", "ORD-10003", "ORD-10004", "ORD-10005",
"ORD-10006", "ORD-10007", "ORD-10008", "ORD-10009", "ORD-10010",
"ORD-10011", "ORD-10012", "ORD-10013", "ORD-10014", "ORD-10015",
"ORD-10016", "ORD-10017", "ORD-10018", "ORD-10019", "ORD-10020" ],
"total": 20,
"hasMore": false
}
}
}
That reply shows the problem with the List<String> form. ORD-100 matches ninety-nine orders in the seed data, our method caps at 20, and the response says "total": 20, "hasMore": false, which tells the client there are exactly twenty and no more. Both numbers are wrong.
Returning a CompleteResult says what is true:
@McpComplete(prompt = "draft_refund_email")
public CompleteResult completeOrderId(String prefix) {
List<String> all = orderService.findIdsStartingWith(prefix, 21);
boolean more = all.size() > 20;
List<String> page = more ? all.subList(0, 20) : all;
return new CompleteResult(new CompleteCompletion(page, page.size(), more));
}
Asking for one more than we intend to return is the usual way to know whether there are more without counting them all.
No model was involved in any of that. Completion is a plain server-side lookup, which is why it belongs to the server that owns the data.
What We Built
order-service exposes all three MCP primitives. Four tools the model can choose, three resources the application can attach, and one prompt a person can run, with completion for its argument.
The capabilities block from Class 2 listed tools, resources, prompts and completions before any of them existed. All four now have something behind them.
Class 6 starts the second application, which connects to this one.
Next: Class 6: Connecting a Client. A second module, an MCP client, and a tool call from Java rather than curl.