Introduction to MCP, Part 2: How the Client Works

In Part 1 we built the real-estate MCP server — tools, resources, and prompts over 2.2M property listings.

This part is about the client — the other half of the conversation. Spoiler: you usually do not write one. Cursor (or Claude Desktop) already is one.

Host, client, server — three roles

┌─────────────────────────────────────────┐
│  Host (Cursor)                          │
│  ┌─────────┐    ┌──────────────────┐   │
│  │   You   │───▶│  LLM (Claude)    │   │
│  └─────────┘    └────────┬─────────┘   │
│                          │              │
│                   ┌──────▼──────┐      │
│                   │ MCP Client  │      │
│                   └──────┬──────┘      │
└──────────────────────────┼──────────────┘
                           │ stdio JSON-RPC
                    ┌──────▼──────┐
                    │ MCP Server  │
                    │ (your code) │
                    └──────┬──────┘
                           │
                    ┌──────▼──────┐
                    │ PostgreSQL  │
                    └─────────────┘
RoleWho owns itJob
HostCursor, Claude DesktopUI, LLM, lifecycle
ClientBuilt into the hostProtocol messages, capability negotiation
ServerYouTools, resources, prompts, data

The host spawns your server as a child process and pipes JSON-RPC over stdin/stdout. No network port on your side.

Connecting Cursor to the server

All the client wiring lives in .cursor/mcp.json:

{
  "mcpServers": {
    "real-estate": {
      "command": "node",
      "args": [
        "/path/to/real-estate/dist/index.js"
      ],
      "env": {
        "DATABASE_URL": "postgresql://postgres:admin@localhost:5432/postgres"
      }
    }
  }
}

What each field does:

FieldPurpose
commandExecutable to spawn (node, npx, python, etc.)
argsArguments — usually your compiled server entry
envEnvironment variables passed to the server process

When Cursor starts (or you reload MCP servers), it:

  1. Spawns node dist/index.js
  2. Sends an initialize handshake
  3. Negotiates capabilities (tools, resources, prompts, sampling, elicitation, roots)
  4. Calls tools/list, resources/list, prompts/list to discover what the server offers
  5. Keeps the process alive, piping messages back and forth

You never import an MCP client SDK in this project. Configuration is the client setup.

What happens when you send a message

Say you type: "Search for for-sale homes in Texas under $250k with at least 3 beds."

1. You type the message in Cursor chat
2. Cursor sends your message + available tool schemas to the LLM
3. LLM decides to call search_listings({
     state: "Texas",
     maxPrice: 250000,
     minBeds: 3,
     status: "for_sale"
   })
4. MCP Client sends tools/call to your server over stdio
5. Server runs SQL against PostgreSQL
6. Server returns JSON listings as tool result text
7. LLM reads the result and writes a natural-language answer
8. You see the formatted response

The server never talks to the LLM directly for basic tool calls. It is a request handler. The host's LLM is the brain; your server is the hands.

Capability negotiation

During initialize, client and server exchange what they support:

Server advertises:

tools      → search_listings, get_listing_by_id, ...
resources  → listings://schema, listings://market/{state}, ...
prompts    → budget_home_search, compare_listings, ...

Client advertises:

sampling    → server can ask the LLM to generate text
elicitation → server can show forms to the user
roots       → client can share workspace folder paths

If the client does not support a capability, server calls to that feature fail gracefully. The real-estate server wraps sampling and elicitation in try/catch for exactly this reason.

Tools from the client's perspective

When the LLM picks a tool, the client sends something like:

{
  "method": "tools/call",
  "params": {
    "name": "get_listing_by_id",
    "arguments": { "id": 225950 }
  }
}

Your handler runs:

async ({ id }) => {
  const listing = await getListingById(id);
  if (!listing) {
    return {
      content: [{ type: "text", text: `No listing found with id ${id}` }],
    };
  }
  return {
    content: [{ type: "text", text: JSON.stringify(listing, null, 2) }],
  };
}

The client passes that text back to the LLM. Every tool returns the same shape: { content: [{ type: "text", text: "..." }] }.

Resources from the client's perspective

Resources work differently — the user or LLM can read them directly by URI, without a tool call.

In Cursor you might:

  • Browse available resources in the MCP panel
  • Ask "read listings://market/overview"
  • Get autocomplete on listings://market/Flor...Florida

The client sends resources/read with the URI. Your server handler returns the JSON payload.

Templated resources also support:

  • resources/list — browse available URIs (all states, first 20 listings)
  • completion/complete — autocomplete template variables as the user types

This is how listings://market/{state} can suggest Florida, Florida Keys, etc.

Prompts from the client's perspective

Prompts appear as slash commands or MCP prompt pickers in the host.

When you invoke budget_home_search with { maxPrice: "300000", state: "Florida" }:

  1. Client sends prompts/get with the name and args
  2. Server fetches listings + market data from Postgres
  3. Server returns pre-built messages (not LLM output)
  4. Host injects those messages into the chat
  5. LLM responds to the rich, data-filled prompt

The server does the data work. The client's LLM does the reasoning work.

Debugging without Cursor

You do not need the full host to test. The MCP Inspector connects directly:

npm run build
npx @modelcontextprotocol/inspector node ./dist/index.js

The Inspector is a minimal MCP client in the browser. You can:

  • List and call tools manually
  • Read resources by URI
  • Invoke prompts with test arguments
  • See raw JSON-RPC messages

Useful when your server logic is fine but Cursor is not picking up a new tool.

Common client-side gotchas

Server not appearing after code changes

Rebuild (npm run build) and reload MCP servers in Cursor. It runs dist/index.js, not src/.

Tool called with wrong arguments

The LLM infers arguments from your description and Zod schema. Vague descriptions → wrong filters. Be specific: "Maximum price in dollars" not just "price".

Empty results

The server returns valid JSON with zero rows. The LLM should say "no matches" — that is correct behavior, not a client bug.

Capability errors

sampling failed or elicitation failed usually means the host does not support that feature yet, or it is disabled. Basic tools still work.

Server vs client responsibilities

TaskServerClient (host)
Store listing data
Run SQL queries
Decide which tool to call✅ (LLM)
Show chat UI
Call the LLM
Spawn server process
Ask user a form (elicitation)requests✅ renders form
Ask LLM for text (sampling)requests✅ calls LLM

What we covered

The client is the messenger between the LLM and your server. You configure it once in mcp.json, and the host handles every tools/call, resources/read, and prompts/get for you.

What's next

Part 3 covers the advanced primitives where the server talks back to the client — asking the LLM to generate text (sampling), asking the user to fill a form (elicitation), and reading workspace folders (roots).