Introduction to MCP, Part 3: Sampling, Elicitation & Roots

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 outputStructured JSON is enough
The answer requires judgmentThe answer is a database query
You want a summary or recommendationYou 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:

FieldOptions
decisionshortlist / skip / need_more_info
notesfree text
scheduleVisityes / no

Good for workflows where the server needs an explicit human decision, not an LLM guess.

Sampling vs elicitation

SamplingElicitation
AsksThe LLMThe user
ReturnsGenerated textForm values
Use whenYou need language or judgmentYou need precise user input
APIcreateMessage()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

ScenarioHow roots help
Code-aware MCP serversKnow which project folder is open
File-reading toolsResolve paths relative to workspace
Context-aware searchInfer user's region from project metadata

Putting it all together

The real-estate server exposes 12 tools across three layers:

LayerToolsPrimitive
Dataget_listings, search_listings, …Standard tools
Interactivesummarize_listing, recommend_from_candidatesSampling
Interactiveguided_home_search, confirm_listing_interestElicitation
Interactivelist_workspace_roots, search_near_workspaceRoots

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

PrimitiveDirectionReal-estate example
SamplingServer → LLMsummarize_listing
ElicitationServer → Userguided_home_search
RootsServer ← Clientlist_workspace_roots

Series recap

  1. Part 1: Server — tools, resources, prompts over 2.2M listings
  2. Part 2: Client — how Cursor connects, calls tools, and reads resources
  3. 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.