Most tools take their inputs up front and return an answer. Two MCP capabilities turn that around and let a tool ask the client for something mid-run:
- Elicitation asks the user for structured input, so a tool can request a missing value or a confirmation instead of failing.
- Sampling asks the client’s LLM for a completion, so a tool can use the model the client already has rather than calling one itself.
Both are server-to-client requests: the tool pauses, the client answers, and the
tool continues. This article builds a FastMCP server that
uses each, and a client that answers them. The final tool chains the two: it
elicits who a bio is for, then samples the model to write it. The stack is
Mac-native: uv and make.
What you will build
- A
registertool that elicits a structured profile from the user. - A
summarizetool that samples the client’s model. - A
write_biotool that chains elicitation then sampling. - A
pytestsuite where an in-memory client plays both the user and the model, and ademoclient that answers the requests over HTTP.
How the callbacks work
A tool takes a Context parameter and calls back to the client:
await ctx.elicit(message, response_type=...)returns anAcceptedElicitation(with.data), aDeclinedElicitation, or aCancelledElicitation, distinguished by.action.await ctx.sample(prompt, ...)returns aSamplingResultwhose.textis the model’s reply.
The client must be prepared to answer, so it supplies two handlers:
elicitation_handler(message, response_type, params, context)returns the user’s input (adictor anElicitResult).sampling_handler(messages, params, context)returns the model’s completion (a string).
A production client shows the user a form and calls a real LLM; a test or script answers programmatically, which is exactly what makes this testable with no user and no model.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Comfort with
async/awaitand dataclasses.
Everything runs through uv and make.
Step 1: Scaffold and lock down hygiene
Create the files
mkdir -p elicitation-sampling-fastmcp-server-macos
cd elicitation-sampling-fastmcp-server-macos
touch .gitignore
Add the code: .gitignore
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
logs/
*.log
*.pid
.DS_Store
Detailed breakdown
- Standard
uv/macOS hygiene; this project has no secrets or generated data.
Step 2: Initialize the project with uv
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A FastMCP server that elicits input and samples the client model"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.3",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
fastmcpprovidesContext.elicitandContext.sample, plus the clientelicitation_handler/sampling_handlerhooks.
Step 3: The server
Three tools. register elicits, summarize samples, and write_bio chains both.
The elicited shapes are plain dataclasses, which FastMCP turns into the schema the
client renders.
Create the file
touch server.py
Add the code: server.py
"""A FastMCP server that asks the client for things mid-tool.
Two MCP capabilities let a tool pause and call back to the client:
- **Elicitation** (`ctx.elicit`) asks the *user* for structured input.
- **Sampling** (`ctx.sample`) asks the client's *LLM* for a completion.
Both are server-to-client requests, so the client must supply an
`elicitation_handler` and a `sampling_handler`. The `write_bio` tool chains the
two: elicit who the bio is for, then sample the model to write it.
"""
import os
from dataclasses import dataclass
from fastmcp import Context, FastMCP
mcp = FastMCP("macmcp")
@dataclass
class Profile:
name: str
age: int
@dataclass
class BioSubject:
name: str
role: str
@mcp.tool
async def register(ctx: Context) -> dict:
"""Ask the user for a profile (name and age) and return it."""
result = await ctx.elicit("Please provide your profile.", response_type=Profile)
if result.action != "accept":
return {"status": result.action}
return {"name": result.data.name, "age": result.data.age}
@mcp.tool
async def summarize(text: str, ctx: Context) -> str:
"""Ask the client's LLM to summarize the text in one sentence."""
result = await ctx.sample(
f"Summarize this in one sentence:\n\n{text}",
system_prompt="You are a concise summarizer.",
)
return result.text
@mcp.tool
async def write_bio(ctx: Context) -> dict:
"""Elicit who the bio is for, then sample the model to write it."""
asked = await ctx.elicit("Who is the bio for?", response_type=BioSubject)
if asked.action != "accept":
return {"status": asked.action}
subject = asked.data
drafted = await ctx.sample(
f"Write a one-sentence professional bio for {subject.name}, "
f"who works as a {subject.role}.",
system_prompt="You write concise, factual professional bios.",
)
return {"name": subject.name, "role": subject.role, "bio": drafted.text}
def main() -> None:
if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "http":
mcp.run(
transport="http",
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8000")),
)
else:
mcp.run() # stdio
if __name__ == "__main__":
main()
Detailed breakdown
ctx: Contextis injected by FastMCP and does not appear in a tool’s public schema, so clients callregister()andwrite_bio()with no arguments.registerelicits aProfile.ctx.elicitsends the message and the dataclass schema to the client. The client returns the user’s input, which FastMCP validates and delivers asresult.data. Checkingresult.actionhandles the user declining or cancelling instead of assuming an answer.summarizesamples the client’s model.ctx.sampleasks the client to run its LLM on the prompt (with an optionalsystem_prompt,temperature, andmax_tokens) and returns the completion asresult.text. The server does not need its own model or API key.write_biochains them. It elicits the subject, then feeds that into a sampling request. This is the pattern behind interactive, model-assisted tools: gather structured input from the user, then ask the model to act on it.
Step 4: Test with a client that plays both roles
Because elicitation and sampling are answered by the client, tests supply handlers that stand in for the user and the model. The in-memory client delivers the same requests an HTTP client would, so the whole flow is exercised deterministically.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_server.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
Add the code: tests/test_server.py
"""Drive the tools through an in-memory client that plays the user and the LLM.
The client supplies an elicitation_handler (stands in for the user) and a
sampling_handler (stands in for the model), so both server-to-client requests
are answered deterministically with no real user and no real LLM.
"""
import pytest
from fastmcp import Client
from fastmcp.client.elicitation import ElicitResult
from server import mcp
async def accept_elicit(message, response_type, params, context):
"""Return canned answers based on what the tool asked for."""
if "bio" in message.lower():
return {"name": "Ada Lovelace", "role": "mathematician"}
return {"name": "Ada Lovelace", "age": 36}
async def decline_elicit(message, response_type, params, context):
return ElicitResult(action="decline")
async def fake_llm(messages, params, context):
"""A deterministic stand-in for the client's model."""
prompt = messages[-1].content.text
return f"[model] {prompt.splitlines()[0][:40]}"
def _client(elicit=accept_elicit, sample=fake_llm):
return Client(mcp, elicitation_handler=elicit, sampling_handler=sample)
async def test_register_accept_returns_profile():
async with _client() as client:
result = await client.call_tool("register", {})
assert result.data == {"name": "Ada Lovelace", "age": 36}
async def test_register_decline_reports_status():
async with _client(elicit=decline_elicit) as client:
result = await client.call_tool("register", {})
assert result.data == {"status": "decline"}
async def test_summarize_uses_the_client_model():
async with _client() as client:
result = await client.call_tool("summarize", {"text": "the quick brown fox"})
assert result.data.startswith("[model]")
async def test_write_bio_chains_elicit_then_sample():
async with _client() as client:
result = await client.call_tool("write_bio", {})
assert result.data["name"] == "Ada Lovelace"
assert result.data["role"] == "mathematician"
assert result.data["bio"].startswith("[model]")
Detailed breakdown
accept_elicitbranches on the message text (which the server controls) to return the right shape for each tool. Returning a plaindictis accepted; the handler could also return anElicitResult.decline_elicitreturnsElicitResult(action="decline"), which the tool sees asresult.action == "decline". This proves the tool handles a user who says no.fake_llmis a deterministic stand-in for the model: it returns a string derived from the prompt so the assertions are stable. A real handler would call an LLM.test_write_bio_chains_elicit_then_sampleconfirms the elicited fields and the sampled text both flow through, exercising the two capabilities together.
Run them:
uv run pytest -v
Step 5: The Makefile
Wrap development, a demo target, and the launchd deployment. Plain make
prints help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# --- Deploy configuration (launchd user agent) --------------------------------
LABEL := com.macmcp.elicit
PLIST := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV := $(shell command -v uv)
DIR := $(shell pwd)
DOMAIN := gui/$(shell id -u)
.PHONY: help install serve test demo deploy undeploy status logs clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
serve: ## Run the HTTP server in the foreground (Ctrl-C to stop)
MCP_TRANSPORT=http uv run python server.py
test: ## Run the test suite
uv run pytest -v
demo: ## Drive elicitation + sampling against a running server
uv run python scripts/demo.py
deploy: ## Render the launchd plist and start the service
@mkdir -p logs
@sed -e 's#@UV@#$(UV)#g' \
-e 's#@DIR@#$(DIR)#g' \
-e 's#@PATH@#$(dir $(UV)):/usr/bin:/bin#g' \
deploy/$(LABEL).plist.template > $(PLIST)
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
launchctl bootstrap $(DOMAIN) $(PLIST)
@echo "Deployed $(LABEL). Check: make status"
undeploy: ## Stop the service and remove the launchd plist
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
rm -f $(PLIST)
@echo "Removed $(LABEL)."
status: ## Show whether the service is registered and running
@launchctl print $(DOMAIN)/$(LABEL) 2>/dev/null \
| grep -E 'state =|pid =' || echo "$(LABEL) is not loaded."
logs: ## Tail the service log files
@tail -n 40 -f logs/server.out.log logs/server.err.log
clean: ## Remove caches and logs
rm -rf .pytest_cache logs __pycache__ tests/__pycache__
Detailed breakdown
demoruns the client from Step 6 against a running server, so you see the full callback flow. The launchd lifecycle matches the companion deploy article.
Step 6: The demo client and deployment
The demo client supplies handlers that answer the server’s requests, so you can watch elicitation and sampling happen over HTTP.
Create the files
mkdir -p scripts deploy
touch scripts/demo.py deploy/com.macmcp.elicit.plist.template
Add the code: scripts/demo.py
"""Drive the server over HTTP, supplying handlers for elicitation and sampling.
The elicitation handler stands in for a user (it returns canned answers), and
the sampling handler stands in for the client's model (a deterministic
transform). A real client would show a form to the user and call an actual LLM.
"""
import asyncio
import os
from fastmcp import Client
URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp/")
async def on_elicit(message, response_type, params, context):
print(f" [user asked] {message}")
if "bio" in message.lower():
return {"name": "Grace Hopper", "role": "computer scientist"}
return {"name": "Grace Hopper", "age": 45}
async def on_sample(messages, params, context):
prompt = messages[-1].content.text
print(f" [model asked] {prompt.splitlines()[0][:50]}...")
# A real client would call an LLM here; we return a deterministic stand-in.
return "Grace Hopper is a pioneering computer scientist and Navy rear admiral."
async def main() -> None:
async with Client(URL, elicitation_handler=on_elicit, sampling_handler=on_sample) as client:
print("register:")
print(" ->", (await client.call_tool("register", {})).data)
print("summarize:")
print(" ->", (await client.call_tool("summarize", {"text": "MCP lets tools call back to the client."})).data)
print("write_bio (elicit -> sample):")
print(" ->", (await client.call_tool("write_bio", {})).data)
if __name__ == "__main__":
asyncio.run(main())
Add the code: deploy/com.macmcp.elicit.plist.template
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.macmcp.elicit</string>
<key>ProgramArguments</key>
<array>
<string>@UV@</string>
<string>run</string>
<string>python</string>
<string>server.py</string>
</array>
<key>WorkingDirectory</key>
<string>@DIR@</string>
<key>EnvironmentVariables</key>
<dict>
<key>MCP_TRANSPORT</key>
<string>http</string>
<key>MCP_HOST</key>
<string>127.0.0.1</string>
<key>MCP_PORT</key>
<string>8000</string>
<key>PATH</key>
<string>@PATH@</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>@DIR@/logs/server.out.log</string>
<key>StandardErrorPath</key>
<string>@DIR@/logs/server.err.log</string>
</dict>
</plist>
Detailed breakdown
on_elicitstands in for the user, returning canned data for each tool. A real client would render a form from the schema and submit what the user typed.on_samplestands in for the model, returning a fixed reply. A real client would call its LLM. Both handlers are passed to theClientand invoked by FastMCP when the server sends a request.
Deploy and run the demo:
make deploy
make status # state = running, a live pid
make demo
# register:
# -> {'name': 'Grace Hopper', 'age': 45}
# summarize:
# -> Grace Hopper is a pioneering computer scientist and Navy rear admiral.
# write_bio (elicit -> sample):
# -> {'name': 'Grace Hopper', 'role': 'computer scientist', 'bio': '...'}
make undeploy
The [user asked] and [model asked] lines print as the server calls back, so you
can watch the round trips.
Troubleshooting
registerreturns{'status': 'decline'}or'cancel'. The client’s elicitation handler declined or cancelled. That is the tool correctly handling a user who said no; return an accepted result to proceed.- The call hangs or errors on elicitation/sampling. The client did not supply
the matching handler. A tool that calls
ctx.elicitneeds anelicitation_handler; one that callsctx.sampleneeds asampling_handler. - Elicitation validation fails. The handler returned data that does not match the requested schema (a missing or wrong-typed field). Return exactly the dataclass’s fields.
- Sampling returns nothing useful with a real client. The client’s model or its configuration is the source of the completion; the server only sends the prompt.
Recap
You built tools that call back to the client:
- Elicitation (
ctx.elicit) asks the user for structured input and handles accept, decline, and cancel. - Sampling (
ctx.sample) asks the client’s LLM for a completion, so the server needs no model of its own. write_biochains the two, the pattern behind interactive, model-assisted tools.- The tests answer both requests programmatically, and
uv,make, andlaunchdrun and deploy the server.
Next improvements
- Elicit a confirmation (a yes/no or an enum) before a destructive action.
- Pass
temperatureandmax_tokenstoctx.sample, ormodel_preferencesto hint which model the client should use. - Combine with the progress article to report progress while a long, multi-sample tool runs.
- Point a real MCP client (with an LLM) at the server so sampling uses an actual model.