MCP Feedback & Elicitation
A connected AI client renders no UI for your server — no button to press, no form to fill. feedbackTools gives the model a way to carry the user's "that was wrong" back to your team, and elicitation lets a tool ask the user a question mid-call and wait for the answer.
#The Feedback Channel
When a user says "that was wrong", the only path back to you is the model relaying it. Every MCP server has this hole, and every one of them hand-rolls the same two tools. feedbackTools() is those two tools with the storage left to you — spread it into your registry and append FEEDBACK_INSTRUCTIONS to your server's instructions:
import { feedbackTools, FEEDBACK_INSTRUCTIONS } from '@absolutejs/mcp';
mcpServer<Caller>({
instructions: `${myInstructions} ${FEEDBACK_INSTRUCTIONS}`,
tools: ({ caller }) => ({
...myTools(caller),
...feedbackTools({
caller,
store: {
// Each handler returns the sentence the model relays back to
// the user — a ticket id, an SLA, a thank-you. Return nothing
// and a sensible default is used.
reportProblem: ({ caller, report }) => file(caller, report),
submitFeedback: ({ caller, feedback }) => record(caller, feedback)
}
})
})
});submit_feedback or report_problem instead of only apologising — and to log the good as well as the bad.#The Two Tools
| Tool | Required | Optional | Purpose |
|---|---|---|---|
report_problem | problem | expected, steps, where | Files a bug on the user’s behalf, in their words, as their account. |
submit_feedback | rating, reason | tool | Records the user’s verdict — rating is ‘good’ or ‘bad’, reason stays in their own words. |
Both store handlers — reportProblem and submitFeedback — return the sentence the model relays back to the user, so you control what it promises them: a ticket id, an SLA, a thank-you. Returning nothing falls back to a sensible default ("Reported. Tell the user it's been filed with the team."). Missing input never throws — the tool answers with a prompt-back string the model can act on.
#Elicitation
A tool that can't finish without something only the user knows can ask them and wait for the answer. Turn it on with elicitation: { enabled: true }, mark the tool mayElicit, and use the { canElicit, elicit } context passed as the handler's second argument:
mcpServer<Caller>({
elicitation: { enabled: true },
tools: () => ({
book_table: {
description: 'Book a table.',
inputSchema: { type: 'object' },
mayElicit: true, // opt in: this tool may ask
handler: async (args, { canElicit, elicit }) => {
if (!canElicit) return 'Tell me the party size first.';
const answer = await elicit({
message: 'How many people?',
requestedSchema: {
type: 'object',
properties: { people: { type: 'integer', minimum: 1 } },
required: ['people']
}
});
if (answer.action !== 'accept') return 'No problem — cancelled.';
return `Booked for ${answer.content.people}.`;
}
}
})
});requestedSchema is a flat object of primitives — string, number, integer, boolean, enum — because the spec restricts it so any client can render a form. The answer's action is one of:
unsupported immediately, so a handler that checks canElicit always has another path.#The Statefulness Trade-Off
Elicitation is the one MCP feature a stateless server cannot do: the question goes out on the SSE stream of an in-flight tools/call, and the client answers on a separate HTTP POST. Two requests have to meet, so the endpoint becomes session-stateful (Mcp-Session-Id).
elicitation/createA question waits timeoutMs (default 2 minutes) before resolving as cancel — a user who walks away must not hold a tool call open forever. A POST carrying an unknown Mcp-Session-Id gets a 404, which tells the client to re-initialize; a DELETE on the endpoint ends the session.
elicitation off and nothing changes: the server stays stateless, tools/call keeps answering with a plain JSON body, and only tools marked mayElicit ever stream.#More Than One Instance
Behind one server the defaults handle it. Behind several, two different things break, and each has a seam: a shared store puts session state where every instance sees it, and a bus routes the user's answer to the instance whose promise is waiting.
elicitation: {
enabled: true,
// (1) The client initializes on A and calls a tool on B, which has
// never heard of the session. Put session state where every
// instance sees it. It is an id and a boolean — nothing
// sensitive, nothing large.
store: {
create: ({ canElicit }) => db.insertSession(canElicit), // → id
get: (id) => db.findSession(id), // → { canElicit } | null
drop: (id) => db.deleteSession(id)
},
// (2) The tool call and its question live on ONE instance, but the
// user's answer POST can land on any of them. A promise cannot
// move, so route the answer to the instance that is waiting —
// over whatever fan-out you already run (Postgres
// LISTEN/NOTIFY, Redis, ...).
bus: {
publish: (answer) => notify('mcp_elicit', answer),
subscribe: (handler) => listen('mcp_elicit', handler)
},
// How long a question waits for a human before it gives up
// (default 2 minutes).
timeoutMs: 120000
}Supply neither and run a single instance (or pin sessions). Supply both and elicitation is safe behind a load balancer with no sticky routing — there is a test for exactly that: instance A asks, the answer lands on B, the bus carries it back, and A's call finishes.
#Consuming a Server That Elicits
createMcpClient() is the other half — a streamable-HTTP client for calling other MCP servers. Pass onElicit and the client declares the elicitation capability, answers the server's questions as they arrive on the stream, and posts the verdict back:
import { createMcpClient } from '@absolutejs/mcp';
const client = createMcpClient({
url: 'https://their.app/mcp',
headers: { authorization: `Bearer ${accessToken}` },
// Passing onElicit is what declares the elicitation capability —
// omit it and servers are told you cannot ask anyone. Return
// decline (the user said no) or cancel (they dismissed it);
// NEVER fabricate content on the user's behalf.
onElicit: async (request) => askTheUser(request)
});
await client.initialize();
const tools = await client.listTools(); // follows nextCursor paging
const result = await client.callTool('book_table', { people: 4 });A host with no handler declines on the user's behalf — the client never answers for them. Safety wrapping around untrusted remote tools — namespacing, injection defense, approval gating — is the host's job.