MCP Protocol Architecture describes communication as mostly flowing from client to server: the client discovers tools, the client invokes them, the client fetches resources. But MCP is a bidirectional protocol. Servers can also initiate requests to clients — and those server-to-client capabilities are called client primitives.
There are three: sampling, elicitation, and roots. Most MCP servers you build will never use all three. But understanding them changes how you design multi-step data workflows, and two of them (elicitation and roots) have direct implications for security.
Sampling: Server-Requested LLM Completions
Sampling lets an MCP server ask the host’s AI model to process or analyze something. Instead of the server returning raw data for the AI to interpret, the server can request a completion mid-execution and use the result to decide what to do next.
The flow looks like this:
- User asks: “Analyze the quality of the orders table”
- Host invokes the
analyze_tabletool on your MCP server - Your server queries the table, gets statistics (null rates, distinct counts, row count)
- Your server sends a sampling request to the client: “Here are the statistics — summarize the key quality issues”
- The client’s LLM generates the summary
- Your server receives the summary and includes it in the tool’s final response
Without sampling, step 4-6 doesn’t exist. The server returns raw statistics and the AI generates the summary after getting the tool result. With sampling, the server can do more sophisticated multi-step reasoning — compute something, interpret it, branch based on the interpretation, return a richer result.
When Sampling Matters for Data Work
Most data engineering MCP servers don’t need sampling. The common pattern — tool fetches data, AI interprets it — covers the majority of use cases.
Sampling is useful when the server needs to make decisions based on AI judgment mid-execution:
- Automated triage: A pipeline monitoring tool fetches error logs, sends them to the LLM for severity classification, then routes different severities to different response paths
- Schema inference: A server fetches sample rows from an unknown table, asks the LLM to infer data types and suggest column descriptions, then structures that output as dbt YAML
- Iterative analysis: A server fetches a query plan, asks the LLM to identify potential performance issues, runs targeted explain queries on the flagged parts, assembles a complete report
In each case, the AI’s judgment is needed partway through the server’s execution — not just at the end.
Capability Declaration
Sampling only works if the client declared it during initialization:
{ "capabilities": { "sampling": {} }}Not all clients support sampling. Before designing server logic that depends on it, check whether your target clients have the capability. Claude Desktop and Claude Code both support it. Verify for others.
Elicitation: Server-Requested User Input
Elicitation allows an MCP server to pause tool execution and ask the user for additional information before continuing. The server doesn’t know everything it needs upfront; it discovers a gap mid-execution and needs input to proceed.
A simple example: a tool to configure a new data pipeline connection. The server starts, detects that the user hasn’t provided credentials for a particular data source, and uses elicitation to prompt the user for them — rather than failing or requiring the user to know upfront exactly what credentials are needed.
# Conceptual example — actual elicitation API varies by SDK@mcp.tool()async def configure_pipeline(source: str, ctx: Context) -> str: """Configure a new data pipeline.""" config = get_source_config(source)
if not config.has_credentials: # Ask the user for what we need credentials = await ctx.elicit( message=f"The {source} connector needs credentials.", schema={ "type": "object", "properties": { "api_key": {"type": "string", "title": "API Key"}, "endpoint": {"type": "string", "title": "Endpoint URL"} }, "required": ["api_key"] } ) config.apply_credentials(credentials)
return configure_source(config)The client receives the elicitation request, presents a form to the user, and sends the response back to the server. The tool continues with the provided input.
Elicitation vs. Tool Parameters
The obvious question: why not just make these required tool parameters?
Tool parameters work when you know upfront what information is needed. Elicitation works when the server discovers what it needs dynamically — based on the current state of the system, the specific resource being accessed, or information that only becomes relevant partway through execution.
For data engineering: configuring access to an unfamiliar API where required credentials vary by account type; setting up pipeline parameters where available options depend on what the server discovers about the target system; prompting for confirmation before a destructive operation (truncating a table, dropping a model).
That last pattern — confirmation before destructive operations — is particularly useful. The server can describe exactly what it’s about to do and ask for explicit approval, even when the user’s original request was general (“clean up old staging tables”).
Security Implications
Elicitation is also a potential attack surface. A malicious MCP server could use elicitation to phish users — presenting a credential prompt that appears to be from a trusted source. Reputable clients mitigate this by clearly labeling elicitation requests with the server identity, but you should be aware of this when evaluating third-party servers. See Security Posture for AI Agents for the broader picture.
Roots: Filesystem Boundaries
Roots are the most security-focused of the three client primitives. A root is a filesystem path (or URI) that the client exposes to the server as an explicit boundary — “you are allowed to access within this scope.”
The mechanism works like this:
- During or after initialization, the client sends the server a list of allowed roots
- These might be
file:///home/user/projects/analyticsorgit:///repo/data-pipeline - The server uses these roots to understand what parts of the filesystem are in scope
- A well-behaved server only accesses paths within the declared roots
{ "jsonrpc": "2.0", "method": "notifications/roots/list_changed"}When roots change (the user opens a different project folder, for example), the client notifies the server so it can update its scope.
Why Roots Matter
Without roots, there’s nothing stopping an MCP server from accessing any part of the filesystem it can reach. A rogue or compromised server could read /etc/passwd, crawl for credential files (~/.aws/credentials, ~/.ssh/id_rsa), or exfiltrate any readable file.
Roots enforce the principle of least privilege at the filesystem level. A server that’s been granted access to your analytics project doesn’t need to see your home directory.
For data engineering servers that access local files — dbt project directories, pipeline configs, local datasets — roots provide a clean way to define what the server is authorized to touch. The client communicates the boundary explicitly rather than relying on server-side permission checks alone.
Roots in Practice
If you’re building a server that operates on local files, query the roots list to validate that requested paths are in scope:
@mcp.tool()async def read_dbt_model(model_path: str, ctx: Context) -> str: """Read the contents of a dbt model file.""" # In a roots-aware implementation, validate against allowed roots # before reading the file allowed_roots = await ctx.get_roots() if not any(model_path.startswith(root.uri) for root in allowed_roots): return f"Error: {model_path} is outside the allowed scope."
with open(model_path) as f: return f.read()Clients that support roots declare it during initialization:
{ "capabilities": { "roots": { "listChanged": true } }}The listChanged: true flag means the client will notify the server if the root list changes during the session — useful for editors where the user might open a different project folder while the server is running.
The Three Together
Most MCP servers for data engineering will use roots (if they touch the filesystem), sometimes use elicitation (for guided setup flows), and rarely use sampling (for servers that need AI judgment mid-execution).
The division of responsibility is worth keeping clear:
- Server primitives (tools, resources, prompts) — what the server exposes to the AI and user
- Client primitives (sampling, elicitation, roots) — what the client exposes to the server, enabling bidirectional workflows and filesystem boundaries
The server-primitive side is where most of the creative work happens when building data engineering integrations. The client-primitive side is where you unlock multi-step reasoning, guided configuration UX, and principled security boundaries. They work together.