Skip to content
Chapter 2 of 6 · Promptsprompts/get and the name field

14 min read

prompts/get and the name field

On this page

Invoking a prompt is two calls and no inference. A client lists what a server offers, a person picks one and fills in its arguments, and the client asks for that prompt by name; the server renders a short conversation and hands it back. No model runs anywhere in that sequence. What comes back is input for somebody else's inference, which is why a server author who reads the returned messages has tested everything the server controls.

The two calls are where the mistakes live, and both mistakes are about identity. A prompt carries two strings and they are for different audiences, so a host has to render one and send the other. A listing that populates a picker looks like this:

json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "prompts": [
      {
        "name": "postmortem",
        "title": "Write a postmortem",
        "description": "Draft a postmortem for a resolved incident, using the timeline and the deploys around the first alert.",
        "arguments": [
          {
            "name": "incident_id",
            "title": "Incident",
            "description": "Incident identifier, e.g. INC-4821",
            "required": true
          }
        ]
      }
    ]
  }
}

name is the identifier — stable, programmatic, the string the server keys its dispatch table on. title is display copy, added so that servers do not have to choose between a machine-friendly key and something a human wants to read in a menu. A host is meant to render title when it exists and fall back to name when it does not, then send name back on invocation. A host that renders the right string and then sends the string it rendered produces this:

json
{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "prompts/get",
  "params": {
    "name": "Write a postmortem",
    "arguments": { "incident_id": "INC-4821" }
  }
}

The server does the correct thing with that, which is to refuse it:

json
{
  "jsonrpc": "2.0",
  "id": 4,
  "error": {
    "code": -32602,
    "message": "Unknown prompt: Write a postmortem"
  }
}

-32602 is the invalid-params code from JSON-RPC, the request envelope MCP rides on: every call carries a method, a params object and an id, and comes back as either a result or an error with a numeric code under the same id. The specification names -32602 for both an unknown prompt name and a missing required argument. The error text is the server's, so the phrasing a user screenshots comes from the server, which is why this failure is reported as a server bug. When a prompt lists and then cannot be fetched, the first thing worth checking is which of the two strings arrived in params.name, and that check costs one log line on the server's prompts/get handler.

Two things about that exchange become obvious once said in plain words. The first is what the two strings are: name is a primary key and title is the label printed on the row. Nobody would send a customer's display name where a customer id belongs, and the only reason this mistake is easy is that both fields hold strings and both read sensibly to a person. The second is why the refusal comes back as an invalid-params error rather than as some prompt-specific code. The method the client called exists and is implemented — the server handles prompts/get — so nothing about the request is unroutable. What is wrong is one value inside params, which is exactly the condition the invalid-params code names, and it is the same condition as a missing required argument for the same reason. That is worth knowing because it means the code tells you the fault is in the parameters and stops there; which parameter, and why, is information only the server's own log can add.

What comes back is a conversation

Sent with the right identifier, prompts/get returns a result whose substance is a messages array. Each message has a role of user or assistant, and a content field holding a single content block — an object with a type, not a bare string.

json
{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "description": "Postmortem draft for INC-4821",
    "messages": [
      {
        "role": "user",
        "content": {
          "type": "text",
          "text": "Incident INC-4821, resolved 02:58 UTC. Deploys in the hour before the first alert:\n- 02:47 checkout-api 7f3c9d1 (Priya)\n- 02:51 flags: checkout.new_pricing -> on\n\nDraft a postmortem with these sections: Impact, Timeline, Contributing factors, What we do not know."
        }
      },
      {
        "role": "assistant",
        "content": {
          "type": "text",
          "text": "Before I draft: do you want customer impact stated in affected sessions or in failed requests? The two differ by roughly 3x for this incident."
        }
      }
    ]
  }
}

The host takes that array and places it into the live conversation, and the user sees two turns appear as if they had happened. Everything the model does next happens because those turns are now in its context. The description field on the result is for the host's own display — a label on the inserted block, or a tooltip — and has no bearing on what the model receives.

Content blocks come in more shapes than text. The same type-tagged structure carries image and audio blocks with base64 data and a mimeType, and a resource block that embeds a resource inline, carrying its uri alongside its text. Embedding is the mechanism people reach for when a prompt needs a document to work with, and it is worth knowing what it costs before reaching: a runbook of 40 KB embedded as a resource block is on the order of 10,000 tokens inserted into the conversation before the user has typed anything, on every invocation, with no cache boundary you control. Chapter 6 comes back to when that is the right trade and when a reference is better.

Both halves of that cost estimate are worth deriving rather than accepting. The token figure comes from a rule of thumb — roughly four bytes to a token for ordinary prose — so a 40 KB document divides down to the order of 10,000 tokens, and the estimate is a starting point rather than a measurement, because code, tables and non-English text all sit at different ratios. The phrase "no cache boundary you control" is the sharper half. A provider discounts a prefix of a request that matches the prefix it processed last time, and an embedded resource does not live in a prefix: it arrives inside messages that are inserted into the middle of a live conversation, at whatever point the user happened to select the menu entry. Everything after that insertion point is new to the provider, and the insertion point moves every time. So the same document embedded in a tool listing might be paid for once and embedded in a prompt result is paid for on every invocation, and the difference is not about the document at all.

typePayload fieldsWhat a host typically does with it
texttextinserts it as the body of the turn
imagedata (base64), mimeTyperenders it, or drops the block when the provider has no vision
audiodata (base64), mimeTyperarely supported; dropped, or transcribed first
resourceresource.uri plus text or blobinlines the contents and keeps the URI as provenance
resource_linkuri, mimeType, nameshows a reference the user or the host may choose to follow

The rows where a host may drop a block are the ones worth designing against. A prompt whose only instruction arrives inside an image produces, in a text-only host, a message containing nothing at all, and the server has no way to learn that this happened. Putting the instructions in a text block and the material in whatever richer block suits it keeps the prompt degrading to something that still works.

The shape of the return has three consequences that a string return would not have. Roles mean the server can seed an assistant turn, which is how the example above asks a clarifying question before any model inference happens — the question is scripted, and it is free. Multiple messages mean the server can lay out a short exchange rather than one wall of instruction, which matters when part of the content is untrusted and needs to sit in its own turn. And blocks with types mean the host can render an image or a resource properly instead of pasting a base64 blob into a text box.

Attributing a failure to a stage

A bug that lands between a host team and a server team takes an afternoon when each side has evidence that it works. What resolves that quickly is a trace with named stages, so that a symptom points at one of them rather than at "the integration".

Sequence: User → Host UI (opens the prompt picker); MCP client → Server (prompts/list); Server → MCP client (name, title, arguments[]); MCP client → Host UI (menu entries); User → Host UI (picks it, fills incident_id); Host UI → MCP client (invoke(name, args)); MCP client → Server (prompts/get {name, arguments}); Server → Server (validate arguments); Server → Server (render messages); Server → MCP client ({description, messages[]}); MCP client → Host UI (messages); Host UI → User (turns appear in the conversation).

Five stages, each with its own signature failure:

StageOwnerSymptom when it breaksFirst thing to look at
Discoveryserver + clientprompt absent from the menu, or menu shows a raw identifierthe prompts capability at initialize; whether title was sent
Schema handlingserver"prompt not found", "missing required argument", or a rendered message with None spliced into itthe params the handler actually received
Transportbothtimeout, connection closed, truncated JSON on large embedspayload size, and whether the response exceeded a frame limit
Renderingservermessages arrive but the text is wrong, stale, or emptythe interpolation, and whether the data fetch inside the handler failed silently
Presentationhostmessages arrive correct and the user sees something elsethe host's insertion logic and any truncation it applies

The discovery stage begins earlier than prompts/list. A server that never declared the prompts capability during initialization is a server whose client will not call prompts/list at all, and the symptom is a completely empty picker with no error anywhere — a silence that looks like a UI bug and is a handshake omission. The list itself is paginated with an optional nextCursor, and a server with sixty prompts that returns the first twenty will look, to a user hunting for the twenty-first, exactly like a server that never registered it.

The listing is also not fixed for the life of a session. A server that sets listChanged in its prompts capability may send notifications/prompts/list_changed when its catalogue changes, and a host that receives it re-lists; a server that adds a prompt without having declared that flag leaves every connected host showing the menu it fetched at connect time, which for a desktop client nobody restarts is measured in days. A prompt that "shipped last week" and is visible only to its author is usually this rather than a deployment that failed.

Rendering is the stage that fails most quietly, since a handler that catches an exception from its data fetch and interpolates an empty string produces a perfectly valid response containing a timeline with no entries in it. One incidents server measured its own rate of that after the fact: of 412 invocations in a month, six returned a postmortem whose Timeline section was empty, and all six fell inside the ninety minutes during which the deploy API was answering 504. The handler wrapped recent_deploys in a try/except that logged a warning and returned an empty list, so prompts/get answered with well-formed messages, the user received a draft with a plausible structure and a missing middle, and the only trace of the fault was a log line nobody was watching. Raising is almost always the better behaviour here, since the person who clicked is present and can retry in ten seconds, whereas the quietly empty section becomes a document somebody files.

The way that measurement establishes its cause is worth walking, because the count on its own establishes nothing. Six failures in 412 invocations is a rate somewhere near one in seventy, which is comfortably inside the range a team writes off as flaky. What promotes it from a rate to a diagnosis is where the six sit in time. A month is roughly seven hundred hours and the outage window is an hour and a half, so the window covers something like one part in five hundred of the elapsed month — and all six land inside it, with none outside. A process failing at random would have to be extraordinarily lucky to arrange that, so the clustering is the evidence and the count is only what made anyone look.

The identity that closes it is that two records nobody designed to be joined agree exactly. The prompt server's list of empty-Timeline responses and the deploy API's list of minutes spent returning 504 overlap with no remainder on the failure side: not one empty Timeline falls outside the outage, and every one of the six falls inside it. That is what licenses isolating the fault to the deploy API rather than to the handler, without reading a single per-invocation trace. It is also the argument for raising rather than swallowing, in a form a reviewer can check — the correlation was only available because someone had recorded the empty results at all, and a handler that returns a plausible document on failure produces no record to correlate.

Presentation is the stage you cannot instrument, and it is where the surprises accumulate. Hosts differ on whether the messages are inserted and sent immediately, inserted into the composer for the user to edit first, or collapsed behind a summary chip. Some truncate long inserted content. Some drop the assistant message entirely, on the reasoning that assistant turns should come from the model, which quietly destroys the scripted-question pattern above. A server that behaves identically in two hosts can produce different conversations in each, and the only reliable way to find out is to open both.

Here is the server side of the trace, written against the low-level API so the wire shapes stay visible:

python
import mcp.types as types
from mcp.server.lowlevel import Server

server = Server("incidents")

@server.list_prompts()
async def list_prompts() -> list[types.Prompt]:
    return [
        types.Prompt(
            name="postmortem",
            title="Write a postmortem",
            description="Draft a postmortem for a resolved incident.",
            arguments=[
                types.PromptArgument(
                    name="incident_id",
                    title="Incident",
                    description="Incident identifier, e.g. INC-4821",
                    required=True,
                )
            ],
        )
    ]

@server.get_prompt()
async def get_prompt(
    name: str, arguments: dict[str, str] | None
) -> types.GetPromptResult:
    log.info("prompts/get name=%r args=%r", name, arguments)
    if name != "postmortem":
        raise ValueError(f"Unknown prompt: {name}")

    args = arguments or {}
    incident_id = args.get("incident_id")
    if not incident_id:
        raise ValueError("Missing required argument: incident_id")

    incident = await load_incident(incident_id)
    return types.GetPromptResult(
        description=f"Postmortem draft for {incident_id}",
        messages=[
            types.PromptMessage(
                role="user",
                content=types.TextContent(
                    type="text", text=render_postmortem(incident)
                ),
            )
        ],
    )

The log.info on the first line of the handler is the whole diagnostic for the identity bug. The SDK converts the raised ValueError into a JSON-RPC error response for you, which is why the handler does not construct one by hand.

The client half is two calls against an already-initialised session, and the SDK hands back the typed result rather than flattening it into a string:

python
prompts = await session.list_prompts()
chosen = next(p for p in prompts.prompts if p.name == "postmortem")

result = await session.get_prompt(
    chosen.name,                                   # not chosen.title
    arguments={"incident_id": "INC-4821"},
)

for message in result.messages:
    conversation.append(
        {"role": message.role, "content": [block_for(message.content)]}
    )

That loop is the host's entire job on this path, and it is where the two protocols meet. The role values are already user and assistant, which are the roles the provider APIs use, so the only translation is from MCP's {type, text} block into whatever block shape the provider expects. Whether the request goes out immediately or waits for the user to edit the inserted turns is the host's choice, and it is the presentation-stage decision that most changes how a prompt feels to use. The comment on the second line of the get_prompt call carries the whole identity rule: chosen.name goes on the wire, and chosen.title went on the screen.

A prompt result is not a tool result

The difference shows up in error handling. A tool result carries an isError flag, and a failing tool hands the model a description of what went wrong so that the model can adapt inside its turn. A prompt has no such flag; failures are protocol errors, they terminate the invocation, and they surface to the person who clicked. That asymmetry is correct given who initiates each one — there is no model turn in flight to recover, and the party who can act on the failure is the user.

It is also not a completion. The server does not call a model, receives no answer, and pays no inference cost; prompts/get on a template with a database lookup behind it is a few milliseconds of work.

And a prompt is not a resource, despite both being read-only from the client's perspective. Resources are addressed by URI and are attached to context by the application; prompts are addressed by name, take arguments, and produce turns. The distinction gets its own treatment in chapter 6, where one workflow is split across all three primitives.

The two strings and the message array are therefore the whole of the wire contract, and most of the operational surface follows from them. name is identity and travels; title is display and does not. The result is a conversation with roles and typed blocks rather than a string, which is what lets a server script an assistant turn, separate untrusted material into its own message, and hand a host something it can render properly. Everything else — the empty picker, the truncated listing, the timeline with no entries, the assistant turn a host discarded — is one of five stages misbehaving, and naming the stages is what turns a report that says "your prompt is broken" into a question with an owner.

The listing above declared incident_id with a name, a title, a description and required: true, and the handler then re-checked it by hand. That hand-written check is not defensive programming that a better declaration would let you delete — the declaration format has no room for a type, a pattern, or a set of permitted values, and understanding why changes how you design any prompt that takes an argument at all.

Prompts — read the current version at https://daviddominguez.dev/learn/en/prompts/prompts-get-and-the-name-field

Back to top