MCP Is Not One-Directional — Here Are 5 Ways Your Server Talks Back
Everyone explains how to call an MCP server. Nobody explains what happens when the server calls back.
MCP Is Not One-Directional — Here Are 5 Ways Your Server Talks Back
Everyone explains how to call an MCP server. Nobody explains what happens when the server calls back.
The Tutorial Gap Nobody Talks About
Every MCP tutorial covers the same thing. Client calls a tool, server runs it, result comes back. That part is easy to teach and easy to learn.
What nobody covers is what the server can do on its own. Mid-execution it can send log messages, stream progress, stop and ask the user something, tell the LLM to reason about a result, push a notification when data changes. None of that requires the client to ask first.
That is the part this article is about.
The Misconception: MCP Is One-Directional
Every article I read drew the same diagram. Client calls server, server returns result. I drew it myself on a whiteboard when I was planning my agent architecture.
The problem is that diagram made me build the wrong thing. I designed my entire agent UI around a request-response mental model like the agent calls a tool, waits or runs another tool call asynchronously, gets an answer. No visibility into what was happening in between. Users saw a spinner and hoped for the best.
Once I discovered the server could talk back — stream progress, send notifications, request the client to make an LLM call , I went back and redesigned parts of the UI around those capabilities. Users could now see what the agent was doing in real time. Tool execution felt transparent instead of mysterious. Usage went up. Support questions about “is it stuck?” went away.
That is not a minor feature discovery. That is an architecture decision. And it starts with understanding that MCP communication does not flow in one direction.

MCP is bidirectional: the server doesn’t just respond, it initiates
The Running Example: hr_server
Writing a tool definition is the easy part. The decorator goes on, the function runs, it works. What takes time is everything after — which tools should a specific agent see, how to make them conversation-state aware, how to enforce strict output schemas so downstream systems do not break. That is where most of the real work lives.
hr_server is the server used throughout this article. It connects an AI agent to a company HR database. Three tools carry the first four mechanisms:
**get_employee** — reads a single employee record by ID. Read-only, safe to call multiple times.**update_salary** — updates base salary. Permanent. Requires explicit user confirmation.**get_department_report** — long-running analytical query. The tool that makes streaming worth understanding.
The fifth mechanism steps away from hr_server entirely — it looks at what happens when a living document connected to a RAG pipeline gets revised. The tools are not the story there. The notification chain is.
One object appears in every mechanism — the Context object, or ctx. FastMCP injects it automatically into any tool function that declares it as a parameter. No instantiation, no manual passing — just declare it and it is there. What it gives is direct access to the client mid-execution, not after the tool finishes but during. Every mechanism in this article is just a different method on this one object.
# hr_server.py — base setup
from fastmcp import FastMCP
mcp = FastMCP(
name="hr_server",
instructions=(
"Use this server to query and manage employee records. "
"Always confirm with the user before modifying any data."
)
)
Mechanism 1: Log Messages — The Server Narrates Itself
The first thing I typed when I found ctx.info() was "Retrieving data and rendering graph". Not because it was elegant — because I just needed to know if the tool was actually running. It was. Problem solved in one line.
That is what ctx.info() is for. It lets the server narrate what it is doing while it is doing it — not after, not in a log file you dig through later, but live, back to the client, mid-execution. And it is not just ctx.info(). There are three levels:
# Inform — tool is running, here is what it is doing
await ctx.info("Looking up employee record...")
# Flag something worth knowing — execution continues
await ctx.warning("Employee record found but status is terminated. Data may be stale.")
# Signal a hard stop — you control what happens next
await ctx.error("Salary exceeds policy maximum of $500,000. Update rejected.")
Three lines. That is the entire API surface for log messages.
**ctx.info()** — use it liberally. Every non-trivial step in a tool deserves a message. Users and agents should never be staring at silence wondering if something broke.
**ctx.warning()** — something worth flagging that does not stop execution. A terminated employee record still returns data — but the agent should know before it acts on it.
**ctx.error() — this one catches people out the first time. It does not raise an exception.** It emits a message and hands control back to you. You decide whether the tool continues, returns early, or does something else entirely. In an agent workflow that distinction matters — an unhandled exception can derail an entire conversation. A clean error message keeps the LLM informed and in control.
Mechanism 2: Progress Streaming — The Server Shows Its Work
Nine seconds. That is how long one of our tools took to run. Not minutes — nine seconds. And yet the support requests came in anyway. “Is the agent stuck?” “Did it crash?” “Why is nothing happening?”
That is the thing about silence in an agent UI. Users do not give it the benefit of the doubt. Nine seconds of nothing feels like a broken product, even when everything is working perfectly. We were spending time on support tickets for a tool that was functioning exactly as designed.
ctx.report_progress() fixed that. One call inside the loop, and suddenly users could see the agent working, record by record, percentage by percentage. The support requests stopped.
Here is what it looks like in get_department_report, a tool that runs a compensation analysis across every employee in a department:
# Inside the loop — emit progress after each record is processed
await ctx.report_progress(
progress=i + 1,
total=total,
message=f"Analyzing {emp['name']}... ({i + 1}/{total})"
)
The client receives a live feed like this:

Streaming real-time progress from MCP server
That single call does three things — tells the client how far along the tool is, how much total work there is, and gives a human-readable message the UI can surface directly. The client computes the percentage. You just pass the numbers.
There are two streaming methods worth knowing and they serve different moments:
**ctx.report_progress()** — use this inside loops where you can measure completion. Anything with a countable number of steps — records, files, API calls — deserves a progress signal.
**ctx.info()** — use this for steps that are hard to quantify. Fetching the initial dataset, compiling the final result, connecting to an external service. Narrative updates, not numeric ones.
In practice, the best long-running tools use both. Progress for the loop, info for the steps around it. Users get a complete picture not just a percentage, but a sense of what the tool is actually doing at each stage.
Mechanism 3: Elicitation — The Server Pauses and Asks
The moment I knew I needed ctx.elicit() was when a user asked the agent to clean up duplicate files. Files they had uploaded themselves. The agent was perfectly capable of deleting them — that was the problem. No confirmation, no pause, just gone. I needed the tool to stop and ask before it did anything irreversible.
This pattern has a formal name in agentic AI — Human-in-the-Loop, or HITL. The idea is straightforward: before an autonomous system takes an irreversible action, a human gets to review and confirm. In traditional ML pipelines, HITL usually means routing a low-confidence prediction to a human reviewer. In agentic systems, it means pausing tool execution before something permanent happens. ctx.elicit() is MCP's native implementation of that pattern.
It does not warn. It does not log. It stops — completely — and waits for an explicit human response before the tool takes another step.
In hr_server we apply the same principle to update_salary. Before a single byte is written to the database, the tool asks:
# Execution stops here — nothing is written until the user responds
response = await ctx.elicit(
message=(
f"You are about to update the salary for {employee_id} "
f"to ${new_salary:,.2f}. This is permanent. Proceed?"
),
schema=SalaryUpdateConfirmation # confirmed: bool
)
if not response.data.confirmed:
return {"status": "cancelled", "message": "Operation cancelled by user."}
A few things worth understanding about how this works.
The schema matters more than it looks. SalaryUpdateConfirmation is a Pydantic model with a single confirmed: bool field. Without it you are parsing "yes", "yeah", "sure go ahead" from free text — which is not something you want sitting between a user and a permanent database write.
The cancellation path returns a message, not an exception. An unhandled exception in a tool can derail an entire agent conversation. A clean structured return gives the LLM something coherent to work with — it surfaces “Operation cancelled by user” and moves on gracefully.
Annotations and elicitation are not doing the same job. Annotations tell the LLM what kind of tool this is before it calls it. Elicitation enforces the human checkpoint at runtime, mid-execution. One informs the model. The other stops it. You need both — and confusing them is one of the more common mistakes I see in early MCP implementations.
“The tool does not just warn that it is about to modify the database. It stops, asks, and waits for an explicit yes before writing a single byte.”
Mechanism 4: Sampling — The Server Asks the LLM to Think
Most developers who build MCP servers stop at the obvious direction — client calls tool, tool returns result. Sampling flips that assumption entirely. With ctx.sample(), the server can reach back to the client mid-tool and say: "I have data. Ask the LLM what it means."
The client makes the LLM call. The response comes back to the server. The tool uses it as part of its own output. The model is not just the caller anymore — it becomes a collaborator inside the tool execution itself.
I found this while going deep into the FastMCP documentation and it stopped me. Not because it was complicated — because it was so clean. There is a whole class of tools where the hard part is not fetching data, it is making sense of it. Sampling hands that job back to the model, right in the middle of the tool, without the agent having to orchestrate it from the outside.
In get_department_report, instead of dumping raw compensation records back to the agent, the tool fetches the data and then asks the LLM to summarize it:
# Server instructs client to make an LLM call mid-tool
summary_response = await ctx.sample(
messages=[{
"role": "user",
"content": (
f"Summarize this compensation data for {department} in {year}. "
f"Highlight outliers and trends.\n\nData: {results}"
)
}],
max_tokens=500
)
ctx.sample() takes a standard messages list — same format as any LLM API call you have made before. max_tokens caps the response. The result comes back as a response object and you access the generated text via summary_response.content.
One thing worth being deliberate about — always return the raw data alongside the summary. The summary is what the user sees. The raw data is what downstream tools, audit logs, or other agents might need. Discarding it because the summary looks good is a decision you will regret when someone asks why the numbers do not match.
Sampling is not a feature you will reach for on every tool. But for tools that retrieve large or complex datasets — reports, logs, analysis outputs — it changes the design entirely. The tool stops being a data pipe and starts being a thinking layer.
Mechanism 5: Resource Subscriptions — The Server Pushes Notifications
Here is something that catches most people off guard when building a RAG pipeline. The agent starts giving wrong answers — not because something broke, but because the document the embeddings were built on got updated and the vector store never found out.
This happens more than expected. Someone revises the API reference, uploads a new version of a policy doc, deprecates a parameter. The source changes. The embeddings stay frozen at the old version. Stale chunks keep getting retrieved and nobody notices until a user complains.
Resource subscriptions fix this. The document gets exposed as a subscribable MCP resource. When it changes, the server notifies the client automatically. The client fetches the new version and triggers re-embedding. That is the whole flow.
One thing to understand — notify_resource_updated() does not push the document to the client. It signals that something changed. The client then calls read_resource() to fetch it. Notifications stay lightweight regardless of document size. A doorbell, not a delivery.
The trigger can be anything — a user uploading a revised document, a webhook from a CMS, a file watcher, a bucket storage event from S3 or GCS. Whatever calls notify_resource_updated() starts the chain.
On the client side, the reaction depends on what changed. Full document rewrite — reindex everything. Only a few sections changed — re-embed those sections, delete the stale chunks, leave the rest. New content added — insert the new chunks, nothing to delete. The key to making partial updates work is having doc_id, section_id and version on every chunk in the vector store. Without that metadata, surgical updates are not possible and the whole document ends up reindexed every time.
MCP handles detection and notification. The pipeline handles re-ingestion. Keep those two things separate and the system stays clean.
What MCP Does and Does Not Do Here
This is an important distinction worth being explicit about. MCP handles detection and notification — it tells your system that something changed and delivers the updated content on request. It does not re-embed, it does not manage your vector store, and it does not decide what kind of change occurred. That logic lives in your pipeline.
notify_resource_updated() does not send the updated document itself. It sends a lightweight signal — "this resource has changed, come and get it." The client then calls read_resource() to fetch the current state. This separation is intentional. It keeps notifications lightweight and lets the client decide when and whether to retrieve, rather than having potentially large documents pushed unsolicited to every subscriber.
Think of it as a doorbell, not a delivery service. MCP rings the bell. Your pipeline answers the door and decides what to do with what it finds.
📌 “MCP rings the bell. Your pipeline answers the door. Clean separation — each layer does exactly one job.”
The key to making partial revision work is metadata on your chunks. Every chunk inserted into your vector store should carry at minimum doc_id, section_id, and version. When a new version arrives, your pipeline filters the vector store for chunks where doc_id matches and version is older than the incoming revision, deletes them, and inserts the freshly embedded replacements. Most vector stores — Qdrant, Pinecone, Weaviate — support metadata filtering natively. The MCP subscription callback is what triggers that pipeline automatically, so your vector store stays current without any manual intervention.
When to Reach for Each Mechanism

Quick guide to MCP mechanisms on when to use logs, progress streaming, elicitation, sampling, and subscriptions
Key Takeaways
- MCP is not one-directional. The client → server call is just the beginning. The server has five distinct ways to communicate back.
- Log messages (
ctx.info,ctx.warning,ctx.error) let the server narrate its own execution. Use them on every non-trivial tool. - Progress streaming (
ctx.report_progress) transforms long-running tools from black boxes into transparent, trustworthy processes. - Elicitation (
ctx.elicit) gives the server a human checkpoint — it pauses execution and waits for explicit user confirmation before proceeding. - Sampling (
ctx.sample) lets the server delegate reasoning back to the LLM mid-tool. This is the mechanism that makes MCP truly bidirectional. - Resource subscriptions decouple the client from polling — the server pushes notifications when data changes, and the client reacts.
- These mechanisms stack. The best production tools combine several — log messages + elicitation on write tools, progress streaming + sampling on long-running reads.
Further reading:
- *FastMCP documentation — the library used throughout this article*
- *The MCP Standard: A Developer’s Guide to Building Universal AI Tools with the Model Context Protocol by Srinivasan Sekar — the most comprehensive book on MCP available right now (but in Typescript)*
메타데이터
- post_id
- c2f19a2e9b5c
- slug
- mcp-server-not-one-directional-c2f19a2e9b5c
- url
- https://pub.towardsai.net/mcp-server-not-one-directional-c2f19a2e9b5c
- canonical_url
- https://pub.towardsai.net/mcp-server-not-one-directional-c2f19a2e9b5c
- author_url
- https://medium.com/@snehasasanapuri
- status
- ok
- fetched_at
- 2026-06-10 15:53:41