docs
    re-site-kit: MCP tools
    v0.6.0.md

    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

    ToolWhat it doesChanges anything?
    search_listingsSearches your current listings by price, city, ZIP, bedrooms, bathrooms, type, status and keywords.No
    get_listingOne listing's details, photos, brokerage credit and page link.No
    list_communitiesYour buildings or communities with how many homes are for sale in each and their price range. Only with the communities option.No
    request_showingFiles 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 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).

    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:

    OptionTypeDefaultWhat it does
    siteUrlstringYour site's public origin, e.g. https://example.com. Listing links are built on it. Required.
    businessNamestringThe business a buyer is dealing with, e.g. Example Realty. It appears in tool descriptions and results. Required.
    timeZonestringThe 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.
    listingsIndexPathstring/listingsYour search or listings page.
    statusesListingStatus[]['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.
    propertyTypesPropertyType[]all sevenThe property types your site carries, offered to the assistant as search's choices.
    disclaimersstring[]MLS or IDX disclaimers that must travel with listing data. They're returned with every search and listing.
    communities{ label: string } | falseoffAdds 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".
    showingRequestsbooleantrueOffer request_showing. Set false to leave it out.
    sitestringMulti-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>,
    ): McpServerInfo

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

    FieldValue for siteUrl: 'https://www.example.com', businessName: 'Example Realty'
    namecom.example/listings (your host reversed, www. dropped, then /listings)
    version1.0.0
    titleExample 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."
    websiteUrlhttps://www.example.com
    instructionsTells 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

    ArgumentTypeWhat it does
    citystringCity name. Exact match, case-insensitive.
    zipstringFive-digit ZIP code.
    min_price, max_pricenumberPrice range in dollars.
    beds_min, baths_minnumberMinimum bedrooms and bathrooms.
    property_typeone of your propertyTypesKind of home.
    statusone of your statusesDefaults to active.
    keywordsstringAn address, street, MLS number or neighborhood. Every word must match.
    sortprice_asc, price_desc, newest or updatedResult order. Left out, results come most recently updated first.
    pagenumberPage number, from 1.
    limitnumberResults per page, 1 to 20. Default 10.
    communitystringWith 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.

    ArgumentRequiredWhat it is
    listing_slugYesThe listing, from search_listings or get_listing.
    first_name, last_nameYesThe buyer's name.
    emailYesThe buyer's email address.
    phoneYesThe buyer's phone number, with area code. The office confirms by phone.
    requested_timesYes1 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_nameYesThe assistant's own name, recorded with the request.
    buyer_confirmedYesMust be true: the buyer asked for this showing and agreed to share their contact details with the office.
    noteNoAnything the buyer wants the agent to know.
    buyer_agreed_to_calls_and_textsNotrue 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.