Class 5: Prompts and Completion
Duration: ~40 minutes | Level: Intermediate | Prerequisites: Class 4: Resources. Still works without an API key or a model.
What We'll Cover
- What a prompt is, and how it differs from a tool and a resource
@McpPromptand@McpArg, and the roles a prompt method produces@McpComplete, so a client can suggest values while either argument is filled inprompts/list,prompts/getandcompletion/completeovercurl
This class carries on from Class 4. If you followed along, keep working in the project you
already have. If you skipped it, clone the class_4
branch to start from the same place:
git clone --branch class_4 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
The Third Primitive
A primitive is one of the three kinds of thing an MCP server can offer. A prompt 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 specification separates the three primitives by who chooses them:
| Primitive | Who chooses it | In order-service | The request that fetches it |
|---|---|---|---|
| Tools | the model, while it answers | the four order tools from Class 3 | tools/call |
| Resources | the application, before it asks | the two policy documents and the order template from Class 4 | resources/read |
| Prompts | the person using the client | draft_refund_email, added below | prompts/get |
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, so exposing it as a prompt keeps the wording in one place, where improving it improves every client at once.
A confirmation like this is usually sent automatically by a shop's platform, from a fixed template, the moment a refund is issued. We draft it by hand because a prompt needs a drafting task a person triggers, and the same mechanics carry over to replies that need a person's judgement.
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, TextContent.builder(instruction).build()));
}
}
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. Every message carries a role, which says who is speaking: USER for the person's turn, ASSISTANT for the model's.
The € is in the format string deliberately. totalAmount is a BigDecimal, which does not carry a currency, and the instruction asks the model to state the amount. Given 179.99 on its own, the model has to choose a unit, and it often chooses dollars. The rest of the course prices orders in euros. formatted delegates to String.format with the JVM default locale, the language and country settings it formats numbers with, so the same line renders two ways:
Order total: €179.99
Order total: €179,99
Return List<PromptMessage> rather than a String. Spring AI accepts five return types, and two of them choose the role for us:
| Return type | What Spring AI sends | Role on the messages |
|---|---|---|
List<PromptMessage> | the messages as written | whatever we set |
PromptMessage | that one message | whatever we set |
GetPromptResult | the messages, plus a description on the result | whatever we set |
String | one message holding the text | ASSISTANT |
List<String> | one message per string | ASSISTANT |
The last two rows are why this class returns a list. A prompt template is an instruction to the model, so it belongs in a USER message, and an ASSISTANT message reads as though the model had already written the email. Any other return type fails to register, with the message Method must return either GetPromptResult, List<PromptMessage>, List<String>, PromptMessage, or String.
TextContent.builder(instruction).build() rather than new TextContent(instruction). The single-argument constructor still compiles, but it carries @Deprecated in MCP SDK 2.0.0, as does the two-argument form. The builder is the supported route.
Completion for the Argument
Both of draft_refund_email's arguments are awkward to type into an empty box. A refund reason is whatever phrase comes to mind, so two people describing the same problem write it two different ways. An order ID has one correct value, and getting it wrong fails the prompt with No order with ID.
MCP has a request for this. While someone is filling in a prompt's arguments, the client sends completion/complete saying which prompt it is, which argument, and what has been typed so far. The server replies with the values that would fit: autocomplete under a search box, with the suggestions coming from the server that holds the data.
The whole exchange runs while a person is still typing:
No model appears on that diagram, because completion serves the user-controlled side of MCP: the client is asking on a person's behalf, before the prompt is fetched at all.
The request may also carry a context.arguments map, holding the values already chosen for the prompt's other arguments, so a server can narrow one field using another: only the orders belonging to the customer already picked, for example.
Class 2 showed "completions":{} in the initialize reply before any method answered it; @McpComplete is what answers it.
@McpComplete marks the method that answers. Add it to RefundPrompts, next to draftRefundEmail, along with the list of reasons and the imports at the top of the file:
import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.ai.mcp.annotation.McpComplete;
private static final List<String> REFUND_REASONS = List.of(
"arrived damaged", "arrived late", "faulty", "no longer needed", "wrong item");
@McpComplete(prompt = "draft_refund_email")
public List<String> completeRefundArgument(McpSchema.CompleteRequest.CompleteArgument argument) {
return switch (argument.name()) {
case "reason" -> REFUND_REASONS.stream()
.filter(reason -> reason.startsWith(argument.value()))
.toList();
case "orderId" -> orderService.findIdsStartingWith(argument.value(), 20);
default -> List.of();
};
}
It goes in RefundPrompts because it needs the same OrderService the prompt method already has, and because the class is a @Component, which is what Spring AI scans. A separate @Component would work too.
One method answers for the whole prompt. @McpComplete(prompt = ...) registers against the prompt, so every argument of draft_refund_email arrives at this one method:
The switch needs a name to switch on, which is why the parameter is a CompleteRequest.CompleteArgument. The argument can arrive in three shapes:
// Illustrative signatures, showing what each parameter type receives.
complete(McpSchema.CompleteRequest request) // the whole request, context.arguments included
complete(McpSchema.CompleteRequest.CompleteArgument argument) // the argument name, and the text typed so far
complete(String typed) // the text typed so far, and nothing else
A method taking a plain String could not tell the two arguments apart, and would offer order IDs to someone typing a refund reason.
startsWith compares the text as it arrives, so the reason branch is case sensitive: someone typing Arr gets an empty list. The orderId branch avoids that: findIdsStartingWith upper-cases the prefix.
@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. Ours returns a List<String>, the least we have to write: Spring AI fills in the rest of the reply, using the list as the values, its size as the total, and false for hasMore.
orderService.findIdsStartingWith is already in the course project, ordinary Spring Data:
// Already in OrderService; nothing to add.
public List<String> findIdsStartingWith(String prefix, int limit) {
return orderRepository
.findByOrderIdStartingWithOrderByOrderIdAsc(
prefix == null ? "" : prefix.toUpperCase(), Limit.of(limit))
.stream()
.map(OrderEntity::getOrderId)
.toList();
}
This endpoint hands out order IDs twenty at a time for any prefix a caller sends, so walking the prefixes reads out the whole order table's key space. The specification's security requirements for completion are to validate every input, rate limit the requests, because a client sends one per keystroke, control access to sensitive suggestions, and prevent completion-based information disclosure. Authorise a completion the same way the data behind it is authorised, so a caller is only offered IDs they may already read.
Try It Over curl
Restart the server and open a session, as in Class 2. Every call after initialize carries the MCP-Protocol-Version header the specification requires.
Opening a session: the two calls from Class 2 (click to expand)
initialize first. The Accept header names both content types, because the server chooses which one it replies with:
curl -i -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
The reply carries an Mcp-Session-Id header. Copy it, then acknowledge the handshake, which the protocol requires before anything else:
curl -i -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' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
That comes back as 202 with an empty body. Restarting the server ends the session, so both calls are needed again after every restart.
On Windows: the same calls in PowerShell (click to expand)
The commands in this section need three changes in PowerShell. Write curl.exe rather than curl, because Windows PowerShell treats curl as an alias for Invoke-WebRequest, which does not take these flags. Line continuations are backticks rather than backslashes. The JSON body needs the most care. PowerShell 7.3 or later passes the single-quoted -d '...' body to curl.exe unchanged. Windows PowerShell 5.1, the version built into Windows 10 and 11, rebuilds the command line, and the inner double quotes do not survive, so the server receives invalid JSON. Saving the body to a file and passing the file name works in both versions. The prompts/list call below comes out like this:
@'
{"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}
'@ | Set-Content -Path body.json
curl.exe -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" `
-H "MCP-Protocol-Version: 2025-11-25" `
-d "@body.json"
The remaining commands, including the two session calls above, change in the same way: curl.exe, backticks at the ends of lines, and the JSON body saved to body.json between @' and '@. Every header stays as in the Unix version, including Mcp-Session-Id and MCP-Protocol-Version.
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' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-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' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-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.
reason goes straight into the instruction, next to the sentences telling the model what to do.
The five phrases in REFUND_REASONS are only a suggestion to whoever fills in the form, because
prompts/get accepts whatever string the client sends. A caller can put their own instructions
where the model reads instructions, which is
prompt injection. The
specification asks servers to validate prompt arguments
before processing them: check reason against REFUND_REASONS and throw an
IllegalArgumentException for anything else, which is how an unknown order ID is already rejected.
Now the completion, starting with reason, as though someone had typed arr there:
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' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"draft_refund_email"},"argument":{"name":"reason","value":"arr"}}}'
{
"result": {
"completion": {
"values": [ "arrived damaged", "arrived late" ],
"total": 2,
"hasMore": false
}
}
}
Two of the five reasons start with arr. "total": 2, "hasMore": false is accurate, because those two are all there are.
Now the same call for orderId:
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' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{"jsonrpc":"2.0","id":5,"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
}
}
}
The same List<String> form produced both replies, and only one of them is true:
| The call | Matches that exist | Values returned | total says | hasMore says | Accurate |
|---|---|---|---|---|---|
reason = arr | 2 | 2 | 2 | false | yes |
orderId = ORD-100 | 99 | 20 | 20 | false | no, on both fields |
ORD-100 matches ninety-nine orders in the seed data and our method caps at 20, so the reply claims twenty is the whole answer. The code did not change between the two calls: the reasons happened to be a complete list, while the order IDs are a page, one slice of a longer list, which List<String> cannot express.
Returning a CompleteResult lets the method set those fields itself. Replace completeRefundArgument with this version:
@McpComplete(prompt = "draft_refund_email")
public McpSchema.CompleteResult completeRefundArgument(McpSchema.CompleteRequest.CompleteArgument argument) {
return switch (argument.name()) {
case "reason" -> completeReason(argument.value());
case "orderId" -> completeOrderId(argument.value());
default -> new McpSchema.CompleteResult(new McpSchema.CompleteResult.CompleteCompletion(List.of()));
};
}
private McpSchema.CompleteResult completeReason(String typed) {
List<String> matches = REFUND_REASONS.stream()
.filter(reason -> reason.startsWith(typed))
.toList();
return new McpSchema.CompleteResult(new McpSchema.CompleteResult.CompleteCompletion(matches, matches.size(), false));
}
private McpSchema.CompleteResult completeOrderId(String typed) {
List<String> all = orderService.findIdsStartingWith(typed, 21);
boolean more = all.size() > 20;
List<String> page = more ? all.subList(0, 20) : all;
Integer total = more ? null : page.size();
return new McpSchema.CompleteResult(new McpSchema.CompleteResult.CompleteCompletion(page, total, more));
}
The reason branch reports what Spring AI already reported for it, an exact total and hasMore as false. Writing it out is what lets the orderId branch say something different.
Replace the method rather than adding a second one. Both would register, and the server holds completions in a map keyed by the prompt, so one would overwrite the other.
completeOrderId asks for 21 rows and returns 20, the usual way to know whether there are more without counting them all. The three fields come out like this:
values: the first twenty rows. The specification caps a reply at 100 values, so twenty is a choice well under that ceiling, sized for what a dropdown can usefully show.hasMore:truewhen a twenty-first row came back. It is defined for exactly this: more exist even when the number is unknown.total: left out when a twenty-first row came back, because capping the query at 21 saves us from counting all ninety-nine. When the page is the whole result, its size is the true total and the field is filled in.
Restart and run both calls again:
| The call | values | total | hasMore |
|---|---|---|---|
reason = arr | arrived damaged, arrived late | 2 | false |
orderId = ORD-100 | the same twenty IDs | absent from the reply | true |
The two arguments are getting different things out of it.
reason | orderId | |
|---|---|---|
| Where the values come from | a constant in the class | a query against the database |
| How many there are | five, always | ninety-nine for ORD-100 |
| Why we complete it | so every refund is described in the same words | so the field only holds an ID that exists |
| What the person gains | the whole phrase after three characters | a prompt that does not fail on a typo |
| Whether the text means something on its own | yes, arrived damaged reads as a reason | no, ORD-10047 is only an identifier |
The last row matters because a completion reply is a list of plain strings, each one carrying the value alone, without a label or a description. The text the person picks is the value submitted, so it suits a value that means something on its own.
Every one of those calls was a database query behind a JSON-RPC method, which is why completion belongs to the server that owns the data.
What We Built
order-service exposes all three MCP primitives. Four tools the model can choose, two resources and one resource template the application can attach, and one prompt a person can run, with completion for both arguments.
The capabilities block from Class 2 advertised resources, prompts and completions before any of them had anything to return. All three answer now, and logging is the entry Class 11 fills, with the server's log messages.
2026-07-28 changes hereThe current spec revision keeps prompts/list and prompts/get as this class uses them. Three things around them move:
- Each result carries a new
resultTypefield. - The
promptscapability is declared in aDiscoverResultinstead of in theinitializereply. notifications/prompts/list_changedreaches only the clients that asked for it over asubscriptions/listenstream.
None of the code in this class changes.
Next: Class 6: Connecting a Client. A second module, an MCP client, and a tool call from Java rather than curl.
Further Reading
- MCP specification 2025-11-25: Prompts: the normative rules behind
prompts/listandprompts/get, thePromptMessageshape, the two role values, and the error codes for a bad name or a missing argument. - MCP specification 2025-11-25: Completion: the full
completion/completerequest and result, the 100-value ceiling, thecontext.argumentsfield, and the security list this class quotes. - MCP specification 2025-11-25: Overview of server features: the control table this class opens with, with an example of each primitive.
- MCP specification 2026-07-28: Prompts: what the current revision changes for prompts, including
resultTypeand the capability moving intoDiscoverResult. - MCP Server Annotations, Spring AI reference: the attributes of
@McpPrompt,@McpArgand@McpComplete, with worked completion examples. - Prompts, Spring AI reference: what a message role is on the Spring AI side, and which class each role maps to, which is what Class 8 does with the messages this class returns.
- LLM01:2025 Prompt Injection: why free text from a caller is validated before it goes inside an instruction to a model.
Sources
- MCP specification 2025-11-25: Overview of server features: prompts are user-controlled, resources application-controlled and tools model-controlled.
- MCP specification 2025-11-25: Prompts: the
prompts/listandprompts/getshapes shown above, theuserandassistantroles,-32602for an invalid name or a missing argument, and that servers SHOULD validate prompt arguments before processing them. - MCP specification 2025-11-25: Completion: the request and result shapes, the maximum of 100 items per response, the
context.argumentsmap of already-resolved arguments, and the security requirements quoted above. - MCP specification 2025-11-25: Transports: that a client MUST send
MCP-Protocol-Versionon every request afterinitialize. - MCP specification 2026-07-28: Prompts: the
resultTypefield, the capability declared inDiscoverResult, andlist_changeddelivered over asubscriptions/listenstream. - MCP Server Annotations, Spring AI reference: that
@McpCompleteuses eitherpromptoruriand never both. - McpSchema.TextContent, Java MCP SDK 2.0.0 javadoc: both
TextContentconstructors carry@Deprecatedin MCP SDK 2.0.0. - String, Java SE 21 API specification:
formattedis equivalent toString.format(this, args), andString.formatuses the default FORMAT-category locale. - Defining query methods, Spring Data JPA: a derived query method can take a
Limit, which is howfindIdsStartingWithcaps its result. - about_Parsing, Microsoft PowerShell documentation: PowerShell 7.3 changed how native command arguments are parsed, which is a breaking change from the Windows PowerShell 5.1 behaviour.
- JSON-RPC 2.0 specification: the request and error shape every call in this class uses, including
-32602for invalid params. - LLM01:2025 Prompt Injection: the risk of placing caller-supplied text into an instruction a model reads.