AbsoluteJS

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:

TS
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)
      }
    })
  })
});
FEEDBACK_INSTRUCTIONS is the load-bearing half
Without it a model treats a complaint as something to apologise for, and the signal dies where it was spoken. The sentence tells the model to call submit_feedback or report_problem instead of only apologising — and to log the good as well as the bad.

#The Two Tools

ToolRequiredOptionalPurpose
report_problemproblemexpected, steps, whereFiles a bug on the user’s behalf, in their words, as their account.
submit_feedbackrating, reasontoolRecords 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:

TS
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:

acceptThe user answered — the values arrive in content, shaped by requestedSchema.
declineThey said no.
cancelThey dismissed it (or never answered in time).
unsupportedThis client can’t ask anyone — check canElicit and take another path.
Two hard rules from the spec
Never fabricate an answer for the user, and never elicit sensitive information. A client that never declared the capability gets 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).

1
ServerThe call opens a stream
A tools/call for a tool marked mayElicit answers over an SSE stream instead of a plain JSON body — the only way to send the user a question mid-call.
2
ServerThe question goes out
The question goes out on the stream: a message plus a flat requestedSchema any client can render as a form.
elicitation/create
3
ClientThe answer comes back
The client shows the user the form and POSTs their answer back as a JSON-RPC response — a separate HTTP request, acknowledged with a bare 202.
4
ServerTwo requests meet
The answer resolves the promise the tool call is awaiting; the tool finishes and its result closes the stream.

A 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.

Off by default, and off means stateless
Leave 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.

TS
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:

TS
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.