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 │
└─────────────┘
| Role | Who owns it | Job |
|---|---|---|
| Host | Cursor, Claude Desktop | UI, LLM, lifecycle |
| Client | Built into the host | Protocol messages, capability negotiation |
| Server | You | Tools, 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:
| Field | Purpose |
|---|---|
command | Executable to spawn (node, npx, python, etc.) |
args | Arguments — usually your compiled server entry |
env | Environment variables passed to the server process |
When Cursor starts (or you reload MCP servers), it:
- Spawns
node dist/index.js - Sends an initialize handshake
- Negotiates capabilities (tools, resources, prompts, sampling, elicitation, roots)
- Calls tools/list, resources/list, prompts/list to discover what the server offers
- 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" }:
- Client sends
prompts/getwith the name and args - Server fetches listings + market data from Postgres
- Server returns pre-built messages (not LLM output)
- Host injects those messages into the chat
- 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
| Task | Server | Client (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).