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.
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-kit6.4.0 or later, for the MCP module and the rate-limited relay. Read site-kit's MCP docs alongside this page.SONOR_API_KEY, as for everything else in this kit.MCP_TRANSPORT_SECRETin every deploy context, for site-kit's relay (generate one withopenssl rand -hex 32).- For
request_showing: a managed form in Sonor with Showing Requests turned on (see step 3).
1. Define the server
// 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:
// 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:
// 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:
// 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):
// 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.
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 already has most of these, which makes it a good choice.
4. Try it
With next dev running, list the tools:
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:
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. 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:
listingPath: (listing) => listing.source_url,listingPath is only called for listings that have a slug; a listing without one always links listingsIndexPath.
realEstateMcpServerInfo
realEstateMcpServerInfo(
options: RealEstateMcpOptions,
overrides?: Partial<McpServerInfo>,
): McpServerInfoThe 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:
- Unless
buyer_confirmedis exactlytrueandassistant_nameis given, the tool refuses and Sonor is never called. - 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. - 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.
- Consent to calls and texts is recorded only when
buyer_agreed_to_calls_and_textsistrue.
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.