Introduction to MCP, Part 1: Building the Server

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:

ColumnWhat it stores
idPrimary key
statusfor_sale, ready_to_build, or sold
priceAsking or sold price
bed, bathBedrooms and bathrooms
city, state, zip_codeLocation
house_sizeInterior sqft
acre_lotLot 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:

ToolWhat it does
get_listingsPaginated list (default 10, max 100)
get_listing_by_idSingle listing by id
get_listings_by_statusFilter by for_sale, ready_to_build, sold
get_listings_by_locationFilter by city, state, or zip
get_listings_by_price_rangeMin/max price
search_listingsCombined 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 state as 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:

URIContent
listings://schemaTable column definitions
listings://statusesValid status enum values
listings://market/overviewGlobal 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:

PromptPurpose
best_listing_for_mePick the best listing from search results
compare_listingsSide-by-side comparison of 2–5 ids
budget_home_searchWithin-budget + stretch options
first_time_buyer_guideAffordability 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

PrimitiveAnalogyReal-estate example
ToolsFunctions the LLM callssearch_listings
ResourcesRead-only files by URIlistings://market/Florida
PromptsPre-built conversation startersbudget_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).