MCP (Model Context Protocol) is a standard way for AI apps to talk to external data and tools. Think of it like USB for LLMs — one plug, many devices.
In this series I walk through a real-estate MCP server I built: ~2.2 million US property listings in PostgreSQL, exposed to Cursor through tools, resources, and prompts.
This first part covers the server — what it is, how it boots, and the three main primitives you register on day one.
The big picture
MCP has two sides:
You (chat) → Host (Cursor) → MCP Client → MCP Server → Your data
- Host — the app you type in (Cursor, Claude Desktop, etc.)
- Client — built into the host; sends JSON-RPC messages
- Server — what you build; exposes tools, resources, prompts
You do not usually write the client. You write the server and configure the host to launch it.
Booting the server
The real-estate server starts in index.ts. Four lines of setup, then stdio transport:
const server = new McpServer(
{
name: "real-estate",
version: "1.0.1",
},
{
instructions:
"Real-estate MCP with listings tools, prompts, resources, plus sampling, elicitation, and roots helpers.",
},
);
registerTools(server);
registerPrompts(server);
registerResources(server);
registerInteractiveTools(server);
const transport = new StdioServerTransport();
await server.connect(transport);
Stdio means the server reads/writes JSON over stdin/stdout. No HTTP port, no browser — the host spawns your process and pipes messages to it.
The instructions field is a system-level hint the host can pass to the LLM so it knows what this server is for.
The data behind it
Everything reads from a PostgreSQL listings table:
| Column | What it stores |
|---|---|
id | Primary key |
status | for_sale, ready_to_build, or sold |
price | Asking or sold price |
bed, bath | Bedrooms and bathrooms |
city, state, zip_code | Location |
house_size | Interior sqft |
acre_lot | Lot size in acres |
Roughly 1.4M for-sale, 812K sold, and 25K ready-to-build listings across the US. Florida, California, and Texas dominate the dataset.
The server never owns the UI. It just answers structured questions about this data.
Primitive 1: Tools
Tools are functions the LLM can call. You define a name, a description, an input schema, and a handler.
Example — fetch listings by status:
server.registerTool(
"get_listings_by_status",
{
description: "Get listings filtered by status (for_sale, ready_to_build, or sold)",
inputSchema: {
status: z.enum(["for_sale", "ready_to_build", "sold"]),
limit: z.number().int().min(1).max(100).optional(),
},
},
async ({ status, limit }) => {
const listings = await getListingsByStatus(status, limit);
return {
content: [{ type: "text", text: JSON.stringify(listings, null, 2) }],
};
},
);
The real-estate server registers six data tools:
| Tool | What it does |
|---|---|
get_listings | Paginated list (default 10, max 100) |
get_listing_by_id | Single listing by id |
get_listings_by_status | Filter by for_sale, ready_to_build, sold |
get_listings_by_location | Filter by city, state, or zip |
get_listings_by_price_range | Min/max price |
search_listings | Combined filters (status, location, price, beds, baths) |
When you ask Cursor "find for-sale homes in Florida under $300k", the model picks search_listings, the server runs SQL, and JSON comes back.
Tool results are always an array of content blocks — usually { type: "text", text: "..." }.
Primitive 2: Resources
Resources are read-only data the client can fetch by URI — like files, but defined by your server.
Static resource — listing schema docs:
server.registerResource(
"listing_schema",
"listings://schema",
{
title: "Listings Schema",
description: "Column names and descriptions for the listings table",
mimeType: "application/json",
},
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(LISTING_SCHEMA, null, 2),
}],
}),
);
Templated resources go further. A pattern like listings://market/{state} can:
- list — return all available states as browseable URIs
- complete — autocomplete
stateas the user types - read — return market analytics for that state
server.registerResource(
"market_by_state",
new ResourceTemplate("listings://market/{state}", {
list: async () => {
const states = await getDistinctStates();
return {
resources: states.map((state) => ({
uri: `listings://market/${encodeURIComponent(state)}`,
name: `${state} market`,
title: `${state} Market Snapshot`,
})),
};
},
complete: {
state: async (value) => {
const states = await getDistinctStates();
return states
.filter((s) => s.toLowerCase().startsWith(value.toLowerCase()))
.slice(0, 20);
},
},
}),
{ title: "Market by State", mimeType: "application/json" },
async (uri, { state }) => {
const analytics = await getMarketAnalytics({ state });
return jsonResource(uri.href, analytics);
},
);
Six resources total:
| URI | Content |
|---|---|
listings://schema | Table column definitions |
listings://statuses | Valid status enum values |
listings://market/overview | Global market analytics |
listings://listing/{id} | Single listing + computed metrics |
listings://market/{state} | Per-state market snapshot |
listings://market/{state}/{city} | Per-city analytics |
Resources are great when the LLM needs reference material without running a tool call — schema docs, market overviews, browseable listing catalogs.
Primitive 3: Prompts
Prompts are reusable conversation starters. The server does not call the LLM — it prepares data and returns a structured message for the host to send.
server.registerPrompt(
"budget_home_search",
{
title: "Budget Home Search",
description: "Find homes within budget plus stretch options with market context",
argsSchema: {
maxPrice: z.string().optional().describe("Maximum price"),
state: z.string().optional().describe("Preferred state"),
},
},
async ({ maxPrice, state }) => {
const withinBudget = await searchListings({
maxPrice: maxPrice ? Number(maxPrice) : undefined,
state,
status: "for_sale",
limit: 5,
});
const market = await getMarketAnalytics({ state, status: "for_sale" });
return {
messages: [{
role: "user",
content: {
type: "text",
text: [
"Help me find homes within my budget.",
"",
"Within budget:",
JSON.stringify(withinBudget, null, 2),
"",
"Market context:",
JSON.stringify(market, null, 2),
].join("\n"),
},
}],
};
},
);
Four prompts in the real-estate server:
| Prompt | Purpose |
|---|---|
best_listing_for_me | Pick the best listing from search results |
compare_listings | Side-by-side comparison of 2–5 ids |
budget_home_search | Within-budget + stretch options |
first_time_buyer_guide | Affordability snapshot + starter homes |
The pattern: fetch real data first, then hand a rich prompt to the LLM.
Project layout
real-estate/
├── src/
│ ├── index.ts # Boot server + stdio
│ ├── tools.ts # 6 listing query tools
│ ├── resources.ts # 6 static + templated resources
│ ├── prompts.ts # 4 advisor prompts
│ ├── interactive.ts # Sampling, elicitation, roots (Part 3)
│ └── database/
│ ├── db.ts # PostgreSQL pool
│ └── listings.ts # SQL queries + analytics
├── .cursor/mcp.json # How Cursor launches the server
└── dist/index.js # Compiled entry point
Running it
npm install
npm run build
npm run inspect # MCP Inspector for debugging
Cursor config (.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"
}
}
}
}
Restart Cursor, and the server appears as an available MCP connection.
What we covered
| Primitive | Analogy | Real-estate example |
|---|---|---|
| Tools | Functions the LLM calls | search_listings |
| Resources | Read-only files by URI | listings://market/Florida |
| Prompts | Pre-built conversation starters | budget_home_search |
What's next
Part 2 covers the client side — how Cursor acts as the host, what happens when you send a message, and how tools actually get invoked.
Part 3 covers the interactive primitives: sampling (server asks the LLM), elicitation (server asks the user), and roots (filesystem boundaries from the client).