A prompt is the third primitive, and the user-controlled one: a message template a person selects by name, typically as a slash command, rather than something the model reaches for or the host attaches automatically.
The three arguments to registerPrompt
server.registerPrompt(
"prompt-name", // 1. name
{ // 2. config
title: "Display Name",
description: "What invoking this prompt will do",
argsSchema: z.object({
argumentName: z.string().describe("What this argument is for")
})
},
({ argumentName }) => ({ // 3. callback
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Instructions for the model, using ${argumentName}.`
}
}
]
})
); The name is what a person types, which is why it surfaces as a slash command in most hosts. Unlike a tool's name, this one is read by a human choosing from a menu.
The config carries a display title, a
description, and argsSchema. That last one is a Zod object schema, same as
a tool's inputSchema, doing the same three jobs: it generates what
prompts/list advertises, validates arguments, and types the callback.
The callback returns { messages }. Each message names a
role, "user" or "assistant", and a content
block. The host hands these to the model in the order you return them, with the caller's arguments
already filled in.
Notice what a prompt does not do. It returns text, not results. It cannot read your data, call your tools, or fetch your resources. All it does is tell the model what to do, and the model decides whether to act on that, which is why the next section matters more than it looks.
✏️ Exercise: a standup prompt
Add a prompt to src/stdio-server.ts that puts the task list to use:
- Named
daily-standup, titledDaily Standup, with a description saying it turns the task list into a short update. - One required argument,
name, a string, describing who the update is for. -
Returning a single
usermessage whose text asks the model to read the tasks resource and write a short standup update for that name.
The catch worth getting right: your callback should not touch the
tasks array or read demo://tasks itself. Write instructions that tell the
model to go read it. Fetching the list yourself and pasting it into the message text would also
produce a standup update, but it would be a different design, and the exercise below shows why.
Verify it
npx @modelcontextprotocol/inspector --cli npx tsx src/stdio-server.ts \
--method prompts/get --prompt-name daily-standup --prompt-args name=Alex {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Read the tasks resource and write a short daily standup update for Alex based on what's there."
}
}
]
}
You get message text back with Alex substituted in, and no task list, because nothing
has read one yet. That is the correct result: a prompt hands over instructions, and the model is
what acts on them.
Now drop the argument and call it again. A prompt with bad arguments fails differently than a tool
does. A tool comes back as a normal result with isError: true, something the model
reads and can react to. A prompt is rejected as a JSON-RPC error before your callback ever runs:
{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Invalid arguments for prompt daily-standup: name: Invalid input: expected string, received undefined"}}
A prompt is invoked by a person through the host's UI, not by the model mid-conversation, so there
is no model on the other end to read a recovery message. The failure goes to whatever is driving the
host instead. Seeing that error is confirmation your argsSchema marked
name as required.
Solution
server.registerPrompt(
"daily-standup",
{
title: "Daily Standup",
description: "Turn the current task list into a short standup update",
argsSchema: z.object({
name: z.string().describe("Who the standup update is for")
})
},
({ name }) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Read the tasks resource and write a short daily standup update for ${name} based on what's there.`
}
}
]
})
); Try it with your assistant
Inspector is deliberately precise: exact arguments in, exact response out. That's not how anyone
will use your server. Restart demo-server the way you did in lesson 3, so your
assistant runs the file as it stands, with the prompt included this time.
The restart reset your server's memory, so add a couple of tasks first, the way you did in lesson 3, so the prompt has something to read.
Then invoke the prompt. In Copilot that's
/demo-server.daily-standup. In Claude Code it's
/mcp__demo-server__daily-standup Alex, with arguments space-separated after the
command. Watch the model read the task list on its own before writing the update, it decides to
fetch demo://tasks, the same resource you attached by hand in lesson 4, without you
telling it to.
Your server now exposes all three primitives.
Check your understanding
Why do prompts fail differently than tools?
A tool with invalid arguments returns a normal result with `isError: true`. A prompt with invalid arguments is rejected as a JSON-RPC error before your callback runs. Why the difference?