Tool kamu udah tahu harus ngapain, cuma belum tahu cukup banyak buat jalan. Folder yang diminta dihapus ternyata masih ada isinya. Ada tiga akun yang namanya cocok dengan yang diketik user. Model boleh nebak, dan sering kali nebak itu nggak masalah, tapi salah hapus folder bukan tebakan yang bisa ditarik balik.
Kebanyakan server MCP nyelesaiin ini dengan cara gagal. Tool-nya error, model baca pesannya, dan loop-nya mulai lagi sementara manusianya ada di luar loop itu. Elicitation itu jawaban dari protokolnya: server nanya di tengah tool call, dan jawabannya masuk balik ke fungsi yang sama.
Semua yang di bawah ini dijalankan di mcp 2.2.0, SDK Python resmi versi sekarang (butuh Python 3.10 ke atas). Server-nya saya bikin, saya jalanin lawan client asli, dan outputnya saya tempel apa adanya.
Yang perlu disiapkan
- Python 3.10+
pip install mcp==2.2.0- Client yang mendeklarasikan dukungan elicitation. Claude Code dan Cursor bisa, Claude Desktop belum, jadi cek punyamu dulu sebelum bangun di atas ini
- Sekitar 15 menit
Form mode dan URL mode
Form mode minta data. Server kirim JSON Schema, client bikin field-nya, dan jawabannya datang udah bertipe. Schema-nya sengaja sempit: objek datar dengan properti primitif saja, artinya string, number, integer, boolean, atau satu enum pilihan tunggal. Nggak ada nesting, nggak ada array of objects.
URL mode minta user pergi ke suatu tempat. Kamu kirim URL, user buka di browser, dan apa pun yang dia ketik di situ nggak lewat client atau model. Kredensial, data kartu, dan layar consent OAuth semuanya masuk mode ini.
Dua-duanya balik dengan salah satu dari tiga aksi: accept, decline, atau cancel. Decline artinya user bilang nggak. Cancel artinya dia nutup dialognya tanpa mutusin apa pun. Dua itu beda, dan spec minta kamu memperlakukannya beda juga.
Bikin: resolver, bukan panggilan di tengah fungsi
SDK Python v2 nyediain dua cara buat nanya. Yang dipakai buat kode baru adalah resolver, di mana pertanyaannya digantung di parameter tool dan framework nanya saat nilainya beneran dibutuhkan.
# server.py
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
Elicit,
ElicitationResult,
Resolve,
)
mcp = MCPServer("Notes")
_FOLDERS: dict[str, list[str]] = {
"/tmp/notes/empty": [],
"/tmp/notes/drafts": ["a.md", "b.md"],
}
class Confirm(BaseModel):
ok: bool
async def confirm_delete(path: str) -> "Confirm | Elicit[Confirm]":
count = len(_FOLDERS.get(path, []))
if count == 0:
return Confirm(ok=True)
return Elicit(f"{path} has {count} file(s). Delete anyway?", Confirm)
@mcp.tool()
async def delete_folder(
path: str,
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
) -> str:
"""Delete a notes folder, asking first when it is not empty."""
match confirm:
case AcceptedElicitation(data=Confirm(ok=True)):
_FOLDERS.pop(path, None)
return f"deleted {path}"
case AcceptedElicitation():
return "kept the folder"
case DeclinedElicitation():
return "declined: folder not deleted"
case CancelledElicitation():
return "cancelled: folder not deleted"
Ada tiga hal yang bikin ini jalan.
confirm_delete baca argumen path milik tool-nya sendiri berdasarkan nama, lalu mutusin apakah perlu nanya sama sekali. Folder kosong balik Confirm(ok=True) dan nggak ganggu client. Keputusan itu ada di Python biasa, bukan di schema.
Anotasi ElicitationResult[Confirm] ngasih seluruh hasilnya ke tool, jadi match-nya bisa bercabang ke accept-dengan-ya, accept-dengan-tidak, decline, dan cancel. Kalau tool kamu cuma peduli nilainya saat accept, anotasi Annotated[Confirm, Resolve(confirm_delete)] saja, dan call-nya berhenti sendiri di decline atau cancel.
Parameter confirm nggak pernah muncul di input schema tool. Client kirim path, resolver nyediain sisanya.
Jalanin
Sisi client cuma satu callback, dan mengoper callback itu sekaligus jadi cara client mendeklarasikan bahwa dia bisa ditanya.
# client.py
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult
from server import mcp
async def handle(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
if isinstance(params, ElicitRequestURLParams):
print(f"open this: {params.url}")
return ElicitResult(action="accept")
print(params.message)
return ElicitResult(action="accept", content={"ok": True})
async def main():
async with Client(mcp, elicitation_callback=handle) as client:
result = await client.call_tool("delete_folder", {"path": "/tmp/notes/drafts"})
print(result.content[0].text)
Ini output aslinya dari tiga skenario: client jawab ya, client jawab nggak, dan client nggak ditanya sama sekali.
== non-empty folder, user says yes ==
[client] asked: /tmp/notes/drafts has 2 file(s). Delete anyway?
[client] schema: {'properties': {'ok': {'title': 'Ok', 'type': 'boolean'}}, 'required': ['ok'], 'type': 'object'}
[client] -> deleted /tmp/notes/drafts
== non-empty folder, user declines ==
[client] asked: /tmp/notes/drafts has 2 file(s). Delete anyway?
[client] -> declined: folder not deleted
== empty folder, no question should be asked ==
[client] -> deleted /tmp/notes/empty
Yang paling penting itu run kedua. Tool-nya tetap balik dengan hasil bersih, karena penolakan itu sebuah hasil yang diproses kode kamu dan dilaporkan normal, bukan jalur error.
Run ketiga itu momen resolver membuktikan dirinya. Nggak ada yang ditanya, karena nggak ada yang perlu ditanya.
Kalau client-nya nggak bisa nanya
Hapus callback-nya, dan call yang sama gagal sebelum body tool kamu jalan:
mcp.shared.exceptions.MCPError: Client did not declare the form elicitation capability
required by resolver 'server:confirm_delete'
Itu bukan decline. Setiap elicitation yang kamu tambahkan butuh jawaban atas "kalau saya nggak bisa nanya?". Biasanya jatuh ke argumen wajib atau pesan error jelas yang bisa dipakai model.
Ada dua batas lain yang perlu kamu tahu sebelum mendesain di atas ini. Schema elicitation itu datar, jadi taruh model di dalam model dan call-nya gagal dengan Error executing tool collect sementara log server nyimpen alasan sebenarnya, yaitu $ref yang bukan schema primitif yang valid. Dan bentuk await ctx.elicit(...) dari SDK cuma jalan di koneksi legacy, yang jadi bahasan berikutnya.
Perubahan transport 2026-07-28
Sebelum 2026-07-28, server nanya dengan cara membuka request-nya sendiri balik ke client di tengah pemrosesan tool call. Back-channel itu yang dipakai await ctx.elicit(...), dan itu sebabnya client uji buat jalur ini harus pakai mode="legacy".
Spec 2026-07-28 memensiunkan back-channel itu. Server sekarang balik, bukan manggil balik: tools/call dapat InputRequiredResult yang bawa input_requests berisi yang masih dibutuhkan server plus request_state sebagai token opaque. Client memenuhi request itu, manggil tool yang sama lagi dengan jawaban di input_responses dan token yang dikirim balik apa adanya, lalu server selesai normal. Semua bagiannya request biasa dari client ke server.
Saya jalanin dua jalur itu di 2.2.0. Versi ctx.elicit membooking tanggal penggantinya di koneksi legacy dan balik dengan Booked a table for 2 on 2025-12-27. Versi resolver ngasih perilaku yang sama tanpa peduli transport apa yang ada di bawahnya. Ketidakpedulian itu alasan buat milih resolver di kode baru.
Aturan keamanan yang nggak bisa dilewat
Aturan ini ada di level MUST di spec, dan alasannya nyata karena mode kegagalannya memang bisa dicapai.
Jangan pakai form mode buat password, API key, access token, atau kredensial pembayaran. Semua itu geser ke URL mode, di mana rahasianya tetap di luar client dan luar konteks model.
Ikat state elicitation ke identitas user, bukan cuma ke session ID. Di server remote, ambil identitas itu dari kredensial yang sudah kamu verifikasi sendiri, bukan dari klaim client.
Pastikan orang yang buka link URL mode adalah orang yang memicunya. Spec langsung nulis seranganannya: user jahat memicu elicitation, meneruskan link-nya ke korban di server yang sama, lalu otorisasi korban berakhir terikat ke sesi penyerang. Itu account takeover yang dikirim dengan nada menolong.
Jangan pernah taruh kredensial, data pribadi, atau link yang sudah terautentikasi di dalam URL URL mode, dan pakai HTTPS begitu keluar dari laptop. Aturan sisi client sama konkretnya: tampilkan URL lengkap sebelum dibuka, jangan pernah pre-fetch, dan buka di tempat yang client dan model nggak bisa baca isi halamannya. Spec nyebut SFSafariViewController sebagai oke dan WKWebView sebagai nggak, dan itu nunjukin seberapa ketat maksudnya.
Kapan elicitation justru alat yang salah
Tanya di awal kalau nilainya bisa diketahui sebelum call. Nganggu orang di tengah kerja itu mahal, dan kalau jawabannya bisa jadi argumen tool, ya seharusnya jadi argumen tool. Elicitation buat hal yang cuma dipegang manusia: konfirmasi sebelum sesuatu yang nggak bisa dibatalkan, dan pilihan di antara kandidat yang semuanya kelihatan sama valid di mata model.
Balikin error biasa kalau modelnya bisa benerin sendiri. Path yang salah atau ID yang nggak dikenal itu informasi yang bisa dipakai model sendirian, jadi sisain interupsinya buat kasus yang salah tebaknya mahal.
Langkah selanjutnya
Batasi seberapa sering satu tool boleh elicitation. Loop pertanyaan itu lebih parah daripada satu kegagalan yang jelas.
Catat setiap elicitation lengkap dengan nama tool dan aksi yang diambil. Seminggu kemudian kamu bakal tahu tool mana yang suka nanya, dan daftar itu jadi antrian perbaikanmu.
Kalau server kamu nyimpen kredensial buat API pihak ketiga, lihat URL mode sebagai alur OAuth client daripada minta key-nya. Spec bahas pola itu detail, termasuk di mana token-nya harus disimpan.