Part 1 covered the server primitives — tools, resources, prompts. Part 2 covered how the client invokes them.
This part covers the interactive primitives — where the server talks back to the client:
- Sampling — server asks the LLM to generate text
- Elicitation — server asks the user to fill a form
- Roots — server reads workspace folders the client shares
All three are implemented in interactive.ts on the real-estate server.
Sampling: the server asks the LLM
Normally the flow is: user → LLM → server → data → LLM → user.
Sampling flips part of it: server → LLM → server.
The server fetches data, sends it to the host's LLM via createMessage(), and gets generated text back.
Example: summarize a listing
server.registerTool(
"summarize_listing",
{
description: "Fetch a listing and use LLM sampling to write a short buyer-friendly summary",
inputSchema: {
id: z.number().int().positive().describe("Listing id to summarize"),
},
},
async ({ id }) => {
const listing = await getListingById(id);
if (!listing) {
return { content: [{ type: "text", text: `No listing found with id ${id}` }], isError: true };
}
const enriched = withListingMetrics(listing);
const response = await server.server.createMessage({
messages: [{
role: "user",
content: {
type: "text",
text: [
"Write a concise buyer-friendly summary of this real-estate listing.",
"Include location, price, beds/baths, size, and one clear pro/con.",
"Keep it under 120 words.",
"",
JSON.stringify(enriched, null, 2),
].join("\n"),
},
}],
maxTokens: 400,
});
return {
content: [{ type: "text", text: response.content.text }],
};
},
);
Flow:
1. LLM calls summarize_listing({ id: 225950 })
2. Server fetches listing from PostgreSQL
3. Server calls createMessage() with listing JSON
4. Client's LLM writes a buyer-friendly paragraph
5. Server returns that text as the tool result
6. Original LLM presents the summary to you
Example: recommend from candidates
summarize_listing generates prose. recommend_from_candidates uses sampling for decisions:
const listings = (await searchListings({
state, city, maxPrice, minBeds, status: "for_sale", limit: 8,
})).map(withListingMetrics);
const response = await server.server.createMessage({
messages: [{
role: "user",
content: {
type: "text",
text: [
"Pick the single best listing for the buyer from these candidates.",
`Priorities: ${priorities ?? "balanced value"}`,
"",
JSON.stringify(listings, null, 2),
].join("\n"),
},
}],
maxTokens: 500,
});
The server does the search. The sampled LLM does the judgment call.
When to use sampling
| Use sampling when… | Use a regular tool when… |
|---|---|
| You need natural language output | Structured JSON is enough |
| The answer requires judgment | The answer is a database query |
| You want a summary or recommendation | You want raw data |
Requires: a client with the sampling capability (Cursor supports this when enabled).
Elicitation: the server asks the user
Tools normally get arguments from the LLM. But the LLM might not know your budget, preferred city, or bedroom count.
Elicitation lets the server pause and show a form to the user mid-tool-call.
Example: guided home search
server.registerTool(
"guided_home_search",
{
description: "Ask the user for home preferences via elicitation, then search matching listings",
inputSchema: {},
},
async () => {
const result = await server.server.elicitInput({
mode: "form",
message: "Tell me what kind of home you want to search for:",
requestedSchema: {
type: "object",
properties: {
state: {
type: "string",
title: "State",
description: "Preferred state or territory",
},
city: {
type: "string",
title: "City",
description: "Preferred city (optional)",
},
maxPrice: {
type: "number",
title: "Max budget",
minimum: 0,
},
minBeds: {
type: "integer",
title: "Minimum bedrooms",
default: 2,
},
priority: {
type: "string",
title: "Top priority",
oneOf: [
{ const: "lowest_price", title: "Lowest price" },
{ const: "value_per_sqft", title: "Best value per sqft" },
{ const: "more_space", title: "More living space" },
{ const: "larger_lot", title: "Larger lot" },
],
default: "value_per_sqft",
},
},
required: ["state", "maxPrice"],
},
});
if (result.action !== "accept" || !result.content) {
return { content: [{ type: "text", text: "Home search cancelled." }] };
}
const listings = await searchListings({
state: result.content.state,
city: result.content.city,
maxPrice: result.content.maxPrice,
minBeds: result.content.minBeds,
status: "for_sale",
limit: 10,
});
return {
content: [{
type: "text",
text: JSON.stringify({ preferences: result.content, listings }, null, 2),
}],
};
},
);
Flow:
1. LLM calls guided_home_search()
2. Server sends elicitInput with a JSON Schema form
3. Client renders the form in the UI
4. User fills in: state=Florida, maxPrice=300000, minBeds=3
5. Client returns the answers to the server
6. Server runs searchListings() with those exact values
7. Results go back to the LLM
The LLM never had to guess your budget. You told the server directly.
Example: confirm listing interest
A second elicitation tool — confirm_listing_interest — loads a listing, then asks:
| Field | Options |
|---|---|
decision | shortlist / skip / need_more_info |
notes | free text |
scheduleVisit | yes / no |
Good for workflows where the server needs an explicit human decision, not an LLM guess.
Sampling vs elicitation
| Sampling | Elicitation | |
|---|---|---|
| Asks | The LLM | The user |
| Returns | Generated text | Form values |
| Use when | You need language or judgment | You need precise user input |
| API | createMessage() | elicitInput() |
Roots: workspace folders from the client
Roots are filesystem directories the client shares with the server — usually your open workspace folders.
The server can call listRoots() to see what the client has exposed:
server.registerTool(
"list_workspace_roots",
{
description: "List the client's workspace roots (folders shared with this server)",
inputSchema: {},
},
async () => {
const result = await server.server.listRoots();
return {
content: [{
type: "text",
text: result.roots.length === 0
? "The client did not share any roots."
: JSON.stringify(result.roots, null, 2),
}],
};
},
);
A root looks like:
{
"uri": "file:///Users/you/projects/real-estate",
"name": "real-estate"
}
Example: search near workspace
search_near_workspace combines roots inspection with a listing search:
const roots = await server.server.listRoots();
const listings = state
? await searchListings({ state, status: "for_sale", limit: 5 })
: [];
return {
content: [{
type: "text",
text: [
"Client workspace roots:",
JSON.stringify(roots.roots, null, 2),
"",
state
? `For-sale listings in ${state}:\n${JSON.stringify(listings, null, 2)}`
: "No state provided — pass a state to search.",
].join("\n"),
}],
};
In this demo, roots are informational — the server lists what Cursor shared, then runs a Postgres search if you provide a state hint. A production version might parse the root path to infer location or read local config files.
When roots matter
| Scenario | How roots help |
|---|---|
| Code-aware MCP servers | Know which project folder is open |
| File-reading tools | Resolve paths relative to workspace |
| Context-aware search | Infer user's region from project metadata |
Putting it all together
The real-estate server exposes 12 tools across three layers:
| Layer | Tools | Primitive |
|---|---|---|
| Data | get_listings, search_listings, … | Standard tools |
| Interactive | summarize_listing, recommend_from_candidates | Sampling |
| Interactive | guided_home_search, confirm_listing_interest | Elicitation |
| Interactive | list_workspace_roots, search_near_workspace | Roots |
Plus 6 resources and 4 prompts from Part 1.
┌─────────────┐
│ User │
└──────┬──────┘
│
┌────────────▼────────────┐
│ Host (Cursor) │
│ LLM + MCP Client │
└──┬──────┬──────┬───────┘
tools/ │ │ │ sampling/
resources│ │ │ elicitation/
prompts │ │ │ roots
▼ ▼ ▼
┌─────────────────┐
│ MCP Server │
│ real-estate │
└──┬──────────┬───┘
│ │
PostgreSQL createMessage()
(listings) elicitInput()
listRoots()
Error handling
Every interactive call is wrapped in try/catch because not all hosts support every capability:
try {
const response = await server.server.createMessage({ ... });
return { content: [{ type: "text", text: response.content.text }] };
} catch (error) {
return {
content: [{ type: "text", text: `Sampling failed: ${error.message}` }],
isError: true,
};
}
If Cursor does not have sampling enabled, the tool fails gracefully instead of crashing the server.
What we covered
| Primitive | Direction | Real-estate example |
|---|---|---|
| Sampling | Server → LLM | summarize_listing |
| Elicitation | Server → User | guided_home_search |
| Roots | Server ← Client | list_workspace_roots |
Series recap
- Part 1: Server — tools, resources, prompts over 2.2M listings
- Part 2: Client — how Cursor connects, calls tools, and reads resources
- Part 3 (this post) — sampling, elicitation, and roots
MCP is not magic. It is a structured pipe between an LLM and your data — and now you have seen every valve on that pipe.