# MCP tools

`@sonordev/re-site-kit/mcp` gives a real estate site a public MCP endpoint, so a buyer's AI assistant can search your live listings, read one in detail, browse your buildings, and ask for a showing. This kit defines the tools; site-kit's MCP module serves them, publishes the discovery card, and puts the endpoint behind a rate limit.

```ts
import { realEstateMcpTools, realEstateMcpServerInfo } from '@sonordev/re-site-kit/mcp'
```

## The tools

| Tool               | What it does                                                                                                                      | Changes anything? |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| `search_listings`  | Searches your current listings by price, city, ZIP, bedrooms, bathrooms, type, status and keywords.                               | No                |
| `get_listing`      | One listing's details, photos, brokerage credit and page link.                                                                    | No                |
| `list_communities` | Your buildings or communities with how many homes are for sale in each and their price range. Only with the `communities` option. | No                |
| `request_showing`  | Files a showing request for someone at your office to confirm with the buyer. Nothing is booked.                                  | Files a request   |

There are no tools that change or remove a listing. Every read uses your site's `SONOR_API_KEY`, which is how Sonor knows the project, so no tool takes a project id and none can reach another site's listings.

## What you need

- `@sonordev/site-kit` 6.4.0 or later, for the MCP module and the rate-limited relay. Read [site-kit's MCP docs](https://sonor.dev/site-kit/mcp) alongside this page.
- `SONOR_API_KEY`, as for everything else in this kit.
- `MCP_TRANSPORT_SECRET` in every deploy context, for site-kit's relay (generate one with `openssl rand -hex 32`).
- For `request_showing`: a managed form in Sonor with **Showing Requests** turned on (see [step 3](#3-turn-on-showing-requests-in-sonor)).

## 1. Define the server

```ts
// lib/mcp.ts
import 'server-only'
import type { McpServerDefinition } from '@sonordev/site-kit/mcp'
import {
  realEstateMcpServerInfo,
  realEstateMcpTools,
  type RealEstateMcpOptions,
} from '@sonordev/re-site-kit/mcp'

const options: RealEstateMcpOptions = {
  siteUrl: 'https://example.com',
  businessName: 'Example Realty',
  timeZone: 'America/Chicago',
  contact: { phone: '555-555-0100', email: 'hello@example.com' },
  disclaimers: ['Listing information is deemed reliable but not guaranteed.'],
}

export const mcpServer: McpServerDefinition = {
  info: realEstateMcpServerInfo(options),
  tools: realEstateMcpTools(options),
}
```

Use your MLS's own disclaimer wording in `disclaimers`; the line above is a placeholder.

## 2. Mount it

This is the same wiring site-kit's MCP docs describe, with your `mcpServer` plugged in. The endpoint route answers only calls that came through the rate-limited relay in production, and answers directly under `next dev`:

```ts
// app/api/mcp/route.ts
import { createMcpHandler } from '@sonordev/site-kit/mcp'
import { protectMcpHandlers } from '@sonordev/site-kit/mcp/transport'
import { mcpServer } from '@/lib/mcp'

export const { POST, GET, DELETE, OPTIONS } = protectMcpHandlers(
  createMcpHandler({
    server: mcpServer,
    baseUrl: 'https://example.com',
    allowedOrigins: '*',
  }),
)
```

The relay's target, which refuses anything the relay didn't sign:

```ts
// app/api/mcp-internal/route.ts
import { createMcpInternalRoute } from '@sonordev/site-kit/mcp/transport'
import * as mcp from '../mcp/route'

export const { POST, GET, DELETE, OPTIONS } = createMcpInternalRoute(mcp)
```

The Netlify function that owns `/api/mcp` in production and applies the rate limit. Netlify reads `path` and `rateLimit` statically, so keep them literal in this file:

```js
// netlify/functions/mcp.mjs
import { createNetlifyMcpRelay } from '@sonordev/site-kit/mcp/transport'

export default createNetlifyMcpRelay()

export const config = {
  path: '/api/mcp',
  rateLimit: { windowSize: 60, windowLimit: 60, aggregateBy: ['ip', 'domain'] },
}
```

The server card, served at both `app/.well-known/mcp-server-card/route.ts` and `app/.well-known/mcp.json/route.ts` (same file contents):

```ts
// app/.well-known/mcp-server-card/route.ts
import { createMcpServerCardHandler } from '@sonordev/site-kit/mcp'
import { mcpServer } from '@/lib/mcp'

export const { GET, OPTIONS } = createMcpServerCardHandler({
  info: mcpServer.info,
  tools: mcpServer.tools,
  baseUrl: 'https://example.com',
})
```

These route files need no segment config on Next 16, and if your site has Cache Components on, Next rejects `dynamic`, `runtime` and `revalidate` exports there anyway.

**Optional extras from site-kit.** On site-kit 7, `onToolCall: reportToolCallsToSonor()` (from `@sonordev/site-kit/mcp/sonor`) in `createMcpHandler`'s options lets Sonor show which assistants called which tools. A custom MCP server like this one doesn't get an "Agent access" section in llms.txt automatically; opt in with `writeLLMsTxtToPublic({ mcp: true })`. Both are covered in [site-kit's MCP docs](https://sonor.dev/site-kit/mcp).

## 3. Turn on showing requests in Sonor

`request_showing` files each request on the managed form in your project that has **Showing Requests** turned on. In the Sonor dashboard, open that form and switch it on in its Real Estate settings. Only one active form per site should have it on.

Give that form `first_name`, `last_name`, `email`, `phone` and `message` fields, plus the hidden `listing_slug`, `listing_key` and `listing_address` fields, so the request lands on the listing in the CRM. A listing [inquiry form](https://sonor.dev/re-site-kit/inquiry-forms) already has most of these, which makes it a good choice.

## 4. Try it

With `next dev` running, list the tools:

```bash
curl -s http://localhost:3000/api/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Then run a search:

```bash
curl -s http://localhost:3000/api/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_listings","arguments":{"max_price":400000,"beds_min":3}}}'
```

## Options

`realEstateMcpTools(options)` and `realEstateMcpServerInfo(options)` take the same `RealEstateMcpOptions`:

| Option              | Type                                 | Default                 | What it does                                                                                                                                                                                                                    |
| ------------------- | ------------------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `siteUrl`           | `string`                             |                         | Your site's public origin, e.g. `https://example.com`. Listing links are built on it. Required.                                                                                                                                 |
| `businessName`      | `string`                             |                         | The business a buyer is dealing with, e.g. `Example Realty`. It appears in tool descriptions and results. Required.                                                                                                             |
| `timeZone`          | `string`                             |                         | The IANA time zone your office schedules showings in, e.g. `America/Chicago`. Required.                                                                                                                                         |
| `contact`           | `{ phone?: string; email?: string }` |                         | How a buyer reaches the office directly. It's included in results, and every error message offers it. Required (it can be `{}`, but give at least one).                                                                         |
| `listingPath`       | `(listing) => string \| null`        | `/listings/{slug}`      | The path or full URL of a listing's page. It gets the listing's `slug`, `source_listing_id` and `source_url`. Return `null` when the site has no page for it, and results link `listingsIndexPath` instead.                     |
| `listingsIndexPath` | `string`                             | `/listings`             | Your search or listings page.                                                                                                                                                                                                   |
| `statuses`          | `ListingStatus[]`                    | `['active', 'pending']` | The statuses search may return and `get_listing` will show. Add `sold` only where your MLS allows sold data in IDX displays, or the listings really are your brokerage's own.                                                   |
| `propertyTypes`     | `PropertyType[]`                     | all seven               | The property types your site carries, offered to the assistant as search's choices.                                                                                                                                             |
| `disclaimers`       | `string[]`                           |                         | MLS or IDX disclaimers that must travel with listing data. They're returned with every search and listing.                                                                                                                      |
| `communities`       | `{ label: string } \| false`         | off                     | Adds `list_communities` and a `community` search filter, for sites with a [building registry](https://sonor.dev/re-site-kit/buildings). The label names them in the tools, e.g. `{ label: 'Building' }` gives "List buildings". |
| `showingRequests`   | `boolean`                            | `true`                  | Offer `request_showing`. Set `false` to leave it out.                                                                                                                                                                           |
| `site`              | `string`                             |                         | Multi-site projects: the host these listings belong to. Every read and the showing request use it.                                                                                                                              |

**Sites without a page per listing.** If your site has one listings page and no route per listing, `listingPath` can return the listing's own `source_url`, so the assistant still links each listing to a real page rather than to your listings index:

```ts
listingPath: (listing) => listing.source_url,
```

`listingPath` is only called for listings that have a slug; a listing without one always links `listingsIndexPath`.

## realEstateMcpServerInfo

```ts
realEstateMcpServerInfo(
  options: RealEstateMcpOptions,
  overrides?: Partial<McpServerInfo>,
): McpServerInfo
```

The server's identity for `createMcpHandler` and the server card:

| Field          | Value for `siteUrl: 'https://www.example.com'`, `businessName: 'Example Realty'`                                                                                                                                                                                                                      |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`         | `com.example/listings` (your host reversed, `www.` dropped, then `/listings`)                                                                                                                                                                                                                         |
| `version`      | `1.0.0`                                                                                                                                                                                                                                                                                               |
| `title`        | `Example Realty listings`                                                                                                                                                                                                                                                                             |
| `description`  | "Search Example Realty's current home listings and ask for a showing. Listing data comes from the live feed behind the website."                                                                                                                                                                      |
| `websiteUrl`   | `https://www.example.com`                                                                                                                                                                                                                                                                             |
| `instructions` | Tells the assistant to search first and then read details, to request a showing only after the buyer asked and agreed to share their name, email and phone, that a request isn't an appointment, and to include the listing brokerage, any disclaimer and the listing's link when it shows a listing. |

Pass `overrides` to change any field, for example `realEstateMcpServerInfo(options, { version: '1.1.0' })`.

## search\_listings

| Argument                 | Type                                             | What it does                                                           |
| ------------------------ | ------------------------------------------------ | ---------------------------------------------------------------------- |
| `city`                   | string                                           | City name. Exact match, case-insensitive.                              |
| `zip`                    | string                                           | Five-digit ZIP code.                                                   |
| `min_price`, `max_price` | number                                           | Price range in dollars.                                                |
| `beds_min`, `baths_min`  | number                                           | Minimum bedrooms and bathrooms.                                        |
| `property_type`          | one of your `propertyTypes`                      | Kind of home.                                                          |
| `status`                 | one of your `statuses`                           | Defaults to `active`.                                                  |
| `keywords`               | string                                           | An address, street, MLS number or neighborhood. Every word must match. |
| `sort`                   | `price_asc`, `price_desc`, `newest` or `updated` | Result order. Left out, results come most recently updated first.      |
| `page`                   | number                                           | Page number, from 1.                                                   |
| `limit`                  | number                                           | Results per page, 1 to 20. Default 10.                                 |
| `community`              | string                                           | With `communities` on: a slug from `list_communities`.                 |

Arguments are checked twice before anything reaches Sonor. site-kit's endpoint refuses a value outside an allowed list (a `status` you didn't enable, say) with an error the assistant can read and correct. Then the tool cleans what's left: numbers can arrive as numeric strings, negative numbers are dropped, `page` is kept between 1 and 500, `limit` is clamped to 1 to 20, and over-long text is dropped. Arguments it doesn't know are ignored, so nothing an assistant sends can point the search at another project.

Each result has the listing's `slug` (for `get_listing` and `request_showing`), `address`, `city`, `state`, `zip`, `status`, `price`, `price_display`, `beds`, `baths`, `square_feet`, `property_type`, `community`, `days_on_market`, `photo` (the first one), `url` (its page, from `listingPath`) and `listing_brokerage`. Alongside the listings come `page`, `total`, `total_pages`, `search_page` (your listings index), your `disclaimers`, a note that listings change daily, and your `contact`.

## get\_listing

Takes one argument, `slug`, from a search result. It returns everything in a search result plus the description (up to 2,000 characters), up to 25 features and 15 building amenities, year built, lot size, garage spaces, HOA fee, frequency and what it includes, whether pets are allowed, up to eight photos, the virtual tour link, open houses, the listing agent's name and when the listing was last updated. Your `disclaimers`, the note and your `contact` come with it.

It refuses without calling Sonor when the slug isn't a valid slug. When the listing doesn't exist or its status isn't in your `statuses` (it sold, say), the assistant gets a plain message that the listing may be off the market, with your office's contact.

## list\_communities

Only offered when `communities` is set. It takes no arguments and returns each building or community with its `slug`, `name`, `homes_for_sale`, `price_min`, `price_max` and `url`. The assistant can pass a `slug` to `search_listings` as `community`.

## request\_showing

Asks your office to show a home. It files a request, not an appointment: someone from your office calls the buyer to confirm a time, and nothing is booked until they do.

| Argument                          | Required | What it is                                                                                                                                       |
| --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `listing_slug`                    | Yes      | The listing, from `search_listings` or `get_listing`.                                                                                            |
| `first_name`, `last_name`         | Yes      | The buyer's name.                                                                                                                                |
| `email`                           | Yes      | The buyer's email address.                                                                                                                       |
| `phone`                           | Yes      | The buyer's phone number, with area code. The office confirms by phone.                                                                          |
| `requested_times`                 | Yes      | 1 to 3 times that work for the buyer, each an ISO 8601 date-time with a UTC offset, e.g. `2026-10-03T10:00:00-05:00`, at least an hour from now. |
| `assistant_name`                  | Yes      | The assistant's own name, recorded with the request.                                                                                             |
| `buyer_confirmed`                 | Yes      | Must be `true`: the buyer asked for this showing and agreed to share their contact details with the office.                                      |
| `note`                            | No       | Anything the buyer wants the agent to know.                                                                                                      |
| `buyer_agreed_to_calls_and_texts` | No       | `true` only if the buyer said the office may call or text them about this home.                                                                  |

What happens:

1. Unless `buyer_confirmed` is exactly `true` and `assistant_name` is given, the tool refuses and Sonor is never called.
2. The request goes from your server to Sonor with your `SONOR_API_KEY`. It's never exposed to in-page (browser) agents, because it needs that server key.
3. Sonor checks the listing is on your site and still showable, validates the requested times, applies its rate limits and spam checks, and files the request as an agent inquiry on your form with Showing Requests on. The request records which assistant sent it, and that the buyer confirmed it.
4. Consent to calls and texts is recorded only when `buyer_agreed_to_calls_and_texts` is `true`.

On success the assistant gets `status: 'requested'`, `booked: false`, a `reference`, the home's address, the requested times as Sonor understood them, a `next_step` telling it to tell the buyer nothing is booked yet, your office's message, and your `contact`.

When Sonor refuses (the listing sold, a time is in the past, too many requests), the assistant gets Sonor's own explanation as a tool error, followed by your office's phone and email, so it can pass that on to the buyer instead of failing silently. If Sonor can't be reached, or `SONOR_API_KEY` isn't set, it gets a plain message to try again or contact the office.

## Agent contact details stay private

No tool returns the listing agent's email or phone number. Results name the listing agent and brokerage, and point the buyer at your office through `contact`.
