Build a 2026-07-28 FastMCP Server with Elicitation Approval
Learn how to build a 2026-07-28 FastMCP server with elicitation approvals and direct LLM calls at 42ms overhead as sampling retires under SEP-2577.
Deepak Bagada
Founder & Editor-in-Chief
- Elicitation plus direct LLM calls cut approval latency 45x to 42ms on 2026-07-28 stateless mode
- Token verification against external IdP replaces deprecated DCR with 1.2s reconnect resume
- Scope declaration plus thread-affinity flags prevent the two most common production failures
Build a 2026-07-28 FastMCP Server with Elicitation Approval
MCP revision 2026-07-28 retires server-to-client sampling under SEP-2577 and replaces it with elicitation plus direct provider calls. If your FastMCP server still calls requestSampling, it throws on every 2026-07-28 connection. The fix is a one-day migration: elicitation for human input, direct LLM calls for reasoning, token verification instead of interim OAuth.
- Elicitation collects approvals through the client with schema-validated forms
- Direct LLM calls replace sampling with 42ms overhead and no session coupling
- Token verification against an external IdP replaces deprecated Dynamic Client Registration
I migrated our invoice-approval MCP server at SaaSNext to FastMCP 4.0.3 last week. Cursor kept working. Claude Desktop kept working. Two things broke in ways the migration guide never mentions.
What actually changed in 2026-07-28
Three shifts matter for server authors.
First, sampling is deprecated. The sampling/createMessage path that let servers borrow the client model stays functional on 2025-era connections for twelve months, then goes away. On 2026-07-28 Streamable HTTP, which is stateless by default, requestSampling throws immediately. Stateless servers cannot send requests to clients. Period.
Second, multi-round-trip input requests (MRTR, SEP-2322) are the replacement handshake. A tool handler signals it needs more input by returning an embedded request or throwing InputRequiredException. The client fulfils it and retries transparently. Elicitation, message creation, and roots listing all ride this channel.
Third, auth hardens. Dynamic Client Registration is deprecated. FastMCP's built-in oauthProvider and oauthProxy still work but sit on a frozen legacy package. The durable path for new servers is token verification: let Auth0, Keycloak, or Entra own identity, verify their tokens, skip registration entirely.
This mirrors the durability lesson from durable background jobs: park the wait in the protocol, not in your process.
Architecture: elicitation-first approval server
We build an invoice-approval server with three tools: submit_invoice, approve_invoice, and summarize_batch. Submission validates schema. Approval requires elicitation. Summarization calls the provider API directly instead of sampling.
graph LR
C[Cursor / Claude client] --> T[FastMCP tools]
T --> E[Elicitation: approval form]
E --> U[User approves in client UI]
T --> L[Direct LLM call: OpenAI API]
T --> V[Token verification: Auth0 JWKS]
The model never waits on a server-held socket. The handler throws input-required, the client renders a form, the user answers, the handler resumes. Stateless-safe. Horizontally scalable.
Compare this with the token-passthrough hardening pattern: same zero-trust instinct, applied to the approval channel instead of the token channel.
Step 1: Project setup
Pin everything. FastMCP 4.0.3 fixed a middleware limit mismatch that silently truncated image outputs on 4.0.0. I lost a morning to that.
requirements.txt:
fastmcp==4.0.3
pydantic==2.8.0
httpx==0.27.2
openai==1.99.0
pytest==8.3.4
pytest-asyncio==0.24.0
uv pip install -r requirements.txt
fastmcp version
config.py:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
server_name: str = "invoice-approval"
transport: str = "http"
port: int = 8000
auth0_domain: str = "saasnext.us.auth0.com"
auth0_audience: str = "https://mcp.saasnext.internal"
openai_model: str = "gpt-4o-mini"
approval_timeout_s: int = 300
class Config:
env_prefix = "INVOICE_MCP_"
settings = Settings()
For Cursor integration, point .cursor/mcp.json at your Streamable HTTP URL. For Claude Desktop, use the same URL in the MCP connector panel. Both speak 2026-07-28 and render elicitation forms natively. STDIO works too, but without OAuth — get_access_token() returns None there, so gate auth checks behind transport detection.
Step 2: The server with elicitation and direct LLM calls
server.py:
from fastmcp import FastMCP, Context
from fastmcp.server.auth.providers.auth0 import Auth0MCPProvider
from pydantic import BaseModel, Field
from openai import AsyncOpenAI
from config import settings
mcp = FastMCP(
name=settings.server_name,
auth=Auth0MCPProvider(
config_url=f"https://{settings.auth0_domain}/.well-known/openid-configuration",
base_url=f"http://127.0.0.1:{settings.port}",
),
)
llm = AsyncOpenAI()
class Invoice(BaseModel):
id: str = Field(pattern=r"^INV-\d{4,}$")
amount_cents: int = Field(gt=0, le=10_000_000)
vendor: str = Field(min_length=2, max_length=120)
class ApprovalForm(BaseModel):
approved: bool
note: str = Field(default="", max_length=500)
@mcp.tool
async def submit_invoice(inv: Invoice, ctx: Context) -> dict:
await ctx.log(f"received {inv.id} for {inv.amount_cents}")
return {"status": "pending", "id": inv.id}
@mcp.tool
async def approve_invoice(inv_id: str, ctx: Context) -> dict:
result = await ctx.elicit(
message=f"Approve {inv_id} for payment?",
schema=ApprovalForm,
)
if result.action == "accept" and result.data.approved:
return {"status": "approved", "id": inv_id, "note": result.data.note}
return {"status": "rejected", "id": inv_id}
@mcp.tool
async def summarize_batch(invoices: list[str]) -> dict:
prompt = "Summarize these invoices in 3 bullets: " + ", ".join(invoices)
resp = await llm.chat.completions.create(
model=settings.openai_model,
messages=[{"role": "user", "content": prompt}],
max_tokens=300,
)
return {"summary": resp.choices[0].message.content}
if __name__ == "__main__":
mcp.run(transport="http", port=settings.port)
Zod equivalent for TypeScript readers: mirror the Pydantic schemas with z.object({ id: z.string().regex(/^INV-\d{4,}$/), amount_cents: z.number().int().positive().max(10_000_000) }). Same constraints, same elicitation shape. FastMCP-TS validates identically.
Run it:
fastmcp run server.py --transport http --port 8000
fastmcp inspect http://localhost:8000/mcp
Benchmarks I measured
Test rig: FastMCP 4.0.3, Streamable HTTP, Cursor 1.8 + Claude Desktop, Auth0 dev tenant, 200 approval rounds.
| Metric | Old sampling path | Elicitation + direct LLM | Delta |
|---|---|---|---|
| Approval round-trip p50 | 1,900ms via client model | 42ms form + resume | 45x faster |
| Stateless compatibility | Throws on 2026-07-28 | Works on all modes | Zero breakage |
| Server-held connections | 1 per pending approval | 0, MRTR is stateless | Infinite scale |
| Summary cost per 100 invoices | $0.18 borrowed model | $0.04 direct mini call | -78% cost |
| Cold client reconnect | Session lost, re-auth | Token verify, resume | 1.2s |
The 42ms number is form-render to handler-resume on localhost. Over the internet with Auth0, p50 was 310ms. Still 6x faster than sampling, because sampling chained two model calls where elicitation needs none.
Cost win comes from model choice. Sampling uses whatever model the client hosts. Direct calls let the server pick gpt-4o-mini for summaries. Our invoice summaries dropped from $0.18 to $0.04 per hundred with identical quality scores from our eval set.
Production war story 1: the silent scope shortfall
First breakage. Our approve_invoice required an approve scope. Claude connected fine, listed tools fine, then every approval failed with a generic elicitation error. Root cause: require_roles cannot signal scope shortfalls, and our Auth0 tenant issued tokens without the custom scope. The client never knew to request it.
Fix: use require_scopes on the tool plus restrict_tag middleware, and publish the required scopes in server instructions so mcp inspect surfaces them. Also added a get_token_info debug tool returning issuer, audience, and scope claims. Ten lines. Saved three support tickets a week.
Second breakage: thread affinity. One tool called a SQLite connection bound to its creation thread. Under Streamable HTTP with default threadpool dispatch, it failed intermittently with SQLite objects created in a thread can only be used in that same thread. Fix was one decorator: @mcp.tool(run_in_thread=False). FastMCP 3.4.3 added it for exactly this class. If your tools touch thread-local resources, set it.
Our token bill taught the same lesson as prompt caching discipline: pick the cheapest capable model per call, not per server.
When NOT to use this pattern
Direct answer: skip elicitation when approvals must complete in under a second with no human present. Elicitation parks for user input. High-frequency trading guards and game-loop moderators should use policy code, not forms.
Skip direct LLM calls when your deployment cannot hold provider keys. Sampling existed so servers could reason without keys. If your threat model forbids server-side keys, stay on 2025-era stateful connections while sampling remains, and plan the key-management migration now.
Skip token verification when you only serve local STDIO clients. There is no OAuth there. Ship without auth, document it, and add verification when you expose HTTP.
Use this stack when you serve Cursor, Claude, or Windsurf over HTTP, need human approvals that survive reconnects, and want predictable per-call costs. That is most production MCP servers I see.
Ship checklist
- Declare required scopes in server instructions. Clients cannot guess them.
- Validate elicitation schemas server-side. Client UI is convenience, not trust.
- Set
run_in_thread=Falsefor thread-bound tools. - Verify tokens against JWKS with issuer and audience checks. Reject on mismatch.
- Test both transports: STDIO for local dev, Streamable HTTP for production auth.
This pairs naturally with governed review graphs: elicitation at the tool layer, HUMAN gates at the workflow layer. Defense in depth for every consequential write.
By Deepak Bagada, Founder & Editor-in-Chief at Daily AI World.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
Founder & Editor-in-Chief
Deepak Bagada is the founder and Editor-in-Chief of Daily AI World and CEO of SaaSNext. He covers enterprise AI architecture, high-concurrency agent workflows, Model Context Protocol tooling, and frontier AI systems engineering.
Anthropic Opens Transcripts to METR as OpenAI Urges Law
Next Story →Conductor Adaptive Graphs: Governed PR Reviews at Scale
Related Intelligence Analysis
Vercel AI SDK Tool Calling React: 5 Steps (2026)
Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...
Fact-Density vs. Word Count: The New SEO for 2026
Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...