Skip to main content

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
  • @McpPrompt and @McpArg, and the roles a prompt method produces
  • @McpComplete, so a client can suggest values while either argument is filled in
  • prompts/list, prompts/get and completion/complete over curl
Companion code

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:

PrimitiveWho chooses itIn order-serviceThe request that fetches it
Toolsthe model, while it answersthe four order tools from Class 3tools/call
Resourcesthe application, before it asksthe two policy documents and the order template from Class 4resources/read
Promptsthe person using the clientdraft_refund_email, added belowprompts/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:

On a full-stop locale, then on a comma-decimal one
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 typeWhat Spring AI sendsRole on the messages
List<PromptMessage>the messages as writtenwhatever we set
PromptMessagethat one messagewhatever we set
GetPromptResultthe messages, plus a description on the resultwhatever we set
Stringone message holding the textASSISTANT
List<String>one message per stringASSISTANT

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();
}
Completion answers whoever is connected

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:

You run this
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:

You run this (with your own Mcp-Session-Id)
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:

You run this, in PowerShell (with your own Mcp-Session-Id)
@'
{"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.

You run this (with your own Mcp-Session-Id)
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:

You run this (with your own Mcp-Session-Id)
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.

The arguments land inside the instruction

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:

You run this (with your own Mcp-Session-Id)
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:

You run this (with your own Mcp-Session-Id)
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 callMatches that existValues returnedtotal sayshasMore saysAccurate
reason = arr222falseyes
orderId = ORD-100992020falseno, 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: true when 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 callvaluestotalhasMore
reason = arrarrived damaged, arrived late2false
orderId = ORD-100the same twenty IDsabsent from the replytrue

The two arguments are getting different things out of it.

reasonorderId
Where the values come froma constant in the classa query against the database
How many there arefive, alwaysninety-nine for ORD-100
Why we complete itso every refund is described in the same wordsso the field only holds an ID that exists
What the person gainsthe whole phrase after three charactersa prompt that does not fail on a typo
Whether the text means something on its ownyes, arrived damaged reads as a reasonno, 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.

What 2026-07-28 changes here

The current spec revision keeps prompts/list and prompts/get as this class uses them. Three things around them move:

  • Each result carries a new resultType field.
  • The prompts capability is declared in a DiscoverResult instead of in the initialize reply.
  • notifications/prompts/list_changed reaches only the clients that asked for it over a subscriptions/listen stream.

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

Sources