Rand Stats

MCP::Server::Tool::Ask

zef:apogee

Actions Status

MCP::Server::Tool::Ask

An ask-the-user tool for MCP::Server, built on genuine server-side elicitation.

Every other tool a model can call is something it does to the world. This is the one that goes the other way: the model has run out of information, and the person who asked for the work is the only one who has it. The pack registers a single tool — ask — which renders a form, blocks until the user fills it in, and hands the answers back to the model as JSON.

Underneath it is the 2026-07-28 multi round-trip request pattern: the tool call is answered with resultType: "input_required", the client shows the user a form, and the call resumes exactly where it stopped. Nothing is polled, nothing is faked client-side, and the handler is never re-run.

Synopsis

Plug it into a server you are building by hand:

use MCP::Server;
use MCP::Server::Tool::Ask;

my $server = MCP::Server.new(:name<assistant>, :version<1.0>);

$server.plug: MCP::Server::Tool::Ask.new;   # registers user_ask

$server.run;   # stdin/stdout

Or name it in a one-shot server, with no use statement at all — the toolkit is loaded at runtime:

use MCP::Server;

MCP::Server.new(
    :name<assistant>,
    :tools['Ask' => { timeout => 120 }],
).run;

Or hand raku-mcp a config file:

{
  "name": "assistant",
  "version": "1.0",
  "instructions": "Ask before guessing.",
  "tools": {
    "Ask": { "timeout": 120 },
    "FileSystem": { "root": "/srv/notes" }
  }
}
raku-mcp --config=assistant.json

# Or without a file:
raku-mcp --tool='Ask={"timeout":120}'

Configuration

KeyTypeDefaultMeaning
timeoutRealunsetSeconds to wait for one answer before treating it as a cancellation. Unset means the wait is bounded only by the server's own C<elicitation-ttl> (five minutes by default). Zero or negative dies.

prefix is reserved by the framework rather than the pack: pass it to .plug as :prefix<...> , or put it beside the toolkit's settings in a :tools entry or config file. Without one, the pack's default prefix user applies, so the tool is user_ask; an explicit empty prefix registers it bare.

What the client has to support

The tool needs somebody to ask. That means one of:

With neither, calling the tool comes back as an error result naming which of the two is missing — it does not hang, and it does not invent an answer. See the "Elicitation" section of MCP::Server's documentation for the whole picture.

The tool

ask(message, questions?)

ParameterTypeRequiredMeaning
messagestringyesThe framing: why the model is asking. Shown above the form.
questionsarraynoThe fields to ask for. Omit it (or send an empty list) to ask one free-form question.

Each entry of questions is an object:

KeyTypeDefaultMeaning
namestringrequiredThe key the answer comes back under. Must be unique within the call.
promptstringWhat to show the user for this field. Becomes the property's C<description>.
typestring"string"One of C<string>, C<number>, C<boolean>.
optionsarrayStrings to choose between; becomes an C<enum>. Only valid on a string question.
requiredbooleantrueWhether the user must fill this field in.

A call with three fields:

{
  "message": "I need a few details before I can file the ticket.",
  "questions": [
    { "name": "component", "prompt": "Which component?", "options": ["api", "ui", "docs"] },
    { "name": "severity",  "prompt": "How bad is it?",   "type": "number" },
    { "name": "notes",     "prompt": "Anything else?",   "required": false }
  ]
}

...becomes this requestedSchema on the wire:

{
  "type": "object",
  "properties": {
    "component": { "type": "string", "description": "Which component?",
                   "enum": ["api", "ui", "docs"] },
    "severity":  { "type": "number", "description": "How bad is it?" },
    "notes":     { "type": "string", "description": "Anything else?" }
  },
  "required": ["component", "severity"]
}

...and comes back to the model as text:

{"component":"api","notes":"","severity":2}

Keys are sorted and the JSON is not pretty-printed: this text goes into a model's context, where stability is worth more than insertion order and newlines are worth nothing at all.

With no questions, the form has a single required string field called answer, which is what a model reaching for "just ask them" wants nine times out of ten:

{"answer":"go ahead, but stage it first"}

Refusal is an answer

A user who declines or cancels gets a plain, non-error result:

Neither carries isError, and that is deliberate. isError tells a model that something went wrong and that trying again might work — so an error result here would send it straight back to ask the same person the same question. A person saying no is the system working. The tool's description tells the model as much: carry on without the answer, or stop and explain why it cannot.

The same goes for a client that answers with something that is not an ElicitResult at all: that is read as a decline rather than as a fault.

Errors

These are error results, because each is a call the model can fix and retry:

And, as above, calling the tool at all when there is nobody to ask.

Security and etiquette

Testing

prove6 -Ilib t/                          # with MCP::Server installed
prove6 -Ilib -I../MCP-Server/lib t/      # against a sibling checkout

t/02-tool.rakutest drives every question shape through a server with a scripted :on-elicit behind it; t/03-mrtr.rakutest does the same thing the long way round, over a real multi round-trip with no local callback anywhere.

Examples

Author

Matt Doughty

License

Artistic-2.0