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 register tool that elicits a structured profile from the user.
  • A summarize tool that samples the client’s model.
  • A write_bio tool that chains elicitation then sampling.
  • A pytest suite where an in-memory client plays both the user and the model, and a demo client 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 an AcceptedElicitation (with .data), a DeclinedElicitation, or a CancelledElicitation, distinguished by .action.
  • await ctx.sample(prompt, ...) returns a SamplingResult whose .text is 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 (a dict or an ElicitResult).
  • 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; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • Comfort with async/await and 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

  • fastmcp provides Context.elicit and Context.sample, plus the client elicitation_handler/sampling_handler hooks.

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: Context is injected by FastMCP and does not appear in a tool’s public schema, so clients call register() and write_bio() with no arguments.
  • register elicits a Profile. ctx.elicit sends the message and the dataclass schema to the client. The client returns the user’s input, which FastMCP validates and delivers as result.data. Checking result.action handles the user declining or cancelling instead of assuming an answer.
  • summarize samples the client’s model. ctx.sample asks the client to run its LLM on the prompt (with an optional system_prompt, temperature, and max_tokens) and returns the completion as result.text. The server does not need its own model or API key.
  • write_bio chains 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_elicit branches on the message text (which the server controls) to return the right shape for each tool. Returning a plain dict is accepted; the handler could also return an ElicitResult.
  • decline_elicit returns ElicitResult(action="decline"), which the tool sees as result.action == "decline". This proves the tool handles a user who says no.
  • fake_llm is 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_sample confirms 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

  • demo runs 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_elicit stands 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_sample stands in for the model, returning a fixed reply. A real client would call its LLM. Both handlers are passed to the Client and 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

  • register returns {'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.elicit needs an elicitation_handler; one that calls ctx.sample needs a sampling_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_bio chains the two, the pattern behind interactive, model-assisted tools.
  • The tests answer both requests programmatically, and uv, make, and launchd run and deploy the server.

Next improvements

  • Elicit a confirmation (a yes/no or an enum) before a destructive action.
  • Pass temperature and max_tokens to ctx.sample, or model_preferences to 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.