Manufact

How good is your MCP? Get a free quality score.Explore catalog

Build a property search MCP app

Andrew KhadderFounding Engineer
Build a property search MCP app

Build a Zillow-style MCP App: listing cards next to an interactive map with price pins. The model opens the view once with search-homes. After that, the view registers its own tools, so follow-ups such as "now the Mission, under $2M" update the open map instead of rendering a second view.

The listings are fictional San Francisco homes. Map tiles come from Esri's keyless Canvas basemaps, so the example needs no listing API or API key.

Try the live example

Ask for homes in San Francisco. Select Fullscreen in the view to see the listing cards, then click a card or pin to load its details.

Open the example in a new tab.

This embedded chat runs its model on Manufact's servers, which cannot reach tools registered inside the view. Follow-up refinements such as "now the Mission, under $2M" need a host that forwards view tools to the model. To try them, run the example with your own model provider API key.

Run the example

The example is a complete project with about 3,500 lines of view code, so clone it instead of copying it. It requires Node.js 22.22.2 or later.

git clone https://github.com/manufacts/mcp-use-property-search-example.git
cd mcp-use-property-search-example
npm ci
npm run dev

Open the Inspector URL printed by the CLI, select Chat, and configure a model provider with your own API key. The Inspector forwards view tools to the model only in this mode; the managed Manufact model cannot call them. Send these prompts in order:

Show me homes in San Francisco.
Now search the Mission, under $2M.
Remove the most expensive home.
Open the cheapest one on the map and save it.
Zoom in one step.

The first prompt calls search-homes and opens the view. Each later prompt calls a view tool, and the open map updates in place. Select Fullscreen in the view to switch display modes.

The project has four main files:

FileContents
src/index.tsThe staged listings, search-homes, and get-listing-details
views/property-search/view.tsxThe view, its view tools, and model context
views/property-search/map.tsxThe Leaflet map, Esri tiles, price pins, and camera controls
views/property-search/cards.tsxListing cards and the detail panel

How it works

The example combines five MCP Apps patterns. Each one works on its own in your server.

Open the view once with the whole dataset

search-homes is the only tool that renders the view. Besides the matching listing IDs, it returns the whole staged catalog in structuredContent, so the view can filter any neighborhood locally without another server call. The tool description tells the model to use the view tools for follow-ups instead of calling search-homes again.

src/index.ts
export const searchHomes = server.tool(
  {
    name: "search-homes",
    title: "Search San Francisco homes",
    description:
      "Open the interactive HomeScout map for San Francisco homes. Call this once to put the map on screen. After that, use the view's tools, such as `search-in-view`, for every follow-up search.",
    inputSchema: z.object({
      location: z.string().optional(),
      maxPrice: z.number().positive().optional(),
      minBeds: z.number().int().min(0).optional(),
      homeType: homeTypeSchema.optional(),
    }),
    outputSchema: z.object({
      area: areaSchema.nullable(),
      matchedIds: z.array(z.string()),
      catalog: z.object({
        listings: z.array(listingSchema),
        areas: z.array(areaMetaSchema),
        attribution: z.string(),
      }),
    }),
    view: {
      name: "property-search",
      prefersBorder: false,
      csp: {
        resourceDomains: ["https://server.arcgisonline.com"],
        connectDomains: ["https://server.arcgisonline.com"],
      },
    },
  },
  async ({ location = "San Francisco", maxPrice, minBeds = 0, homeType }) => {
    const area = resolveArea(location);
    const matched = LISTINGS.filter(
      (listing) =>
        (area === null || listing.area === area) &&
        (maxPrice === undefined || listing.price <= maxPrice) &&
        listing.beds >= minBeds &&
        (homeType === undefined || listing.homeType === homeType)
    );
    return {
      content: [
        {
          type: "text",
          text: `HomeScout is open with ${matched.length} homes. Refine from here with the view tools.`,
        },
      ],
      structuredContent: {
        area,
        matchedIds: matched.map((listing) => listing.id),
        catalog: { listings: LISTINGS, areas: AREAS, attribution: ATTRIBUTION },
      },
    };
  }
);

This excerpt is shortened. The example also accepts minPrice and minBaths filters and returns the median price.

Let the model drive the open view

useViewTool() registers a tool that exists only while the view is mounted. The host lists it to the model, and the handler runs inside the view with access to React state and refs. The example registers seven view tools. This one zooms the Leaflet map:

views/property-search/view.tsx
const zoomMapDefinition = {
  name: "zoom-map",
  title: "Zoom the map",
  description: "Zoom the live map in or out by whole steps.",
  inputSchema: z.object({
    direction: z.enum(["in", "out"]),
    steps: z.number().int().min(1).max(4).optional().describe("Defaults to 1"),
  }),
  outputSchema: z.object({ zoom: z.number() }),
} as const;
 
// Inside the view component:
useViewTool<typeof zoomMapDefinition>(
  zoomMapDefinition,
  async ({ direction, steps = 1 }) => {
    mapRef.current?.zoomBy(direction === "in" ? steps : -steps);
    const zoom = mapRef.current?.zoom() ?? 0;
    return {
      content: [{ type: "text", text: `Zoomed ${direction} to level ${zoom.toFixed(1)}.` }],
      structuredContent: { zoom },
    };
  }
);
View toolPurpose
search-in-viewChange the area, price, beds, baths, home type, or sort in place
remove-listingsHide homes from the cards and map
select-listingFly to a home and open its detail card
save-listingsSave or unsave homes
fit-visible-resultsFit every visible home in frame
zoom-mapZoom in or out
pan-mapPan north, south, east, or west

Keep the model in sync with the view

Users also filter, remove, and select homes by clicking. <ModelContext> reports the current view state to the model, so "remove the most expensive one" refers to what is on screen now:

views/property-search/view.tsx
<ModelContext
  content={[
    "HomeScout map is open. Use the view tools for every follow-up.",
    `Area: ${areaLabel}. Filters: ${describeFilters(effectiveFilters)}. Sort: ${sort}.`,
    `Visible (${visible.length}): ${visible
      .map((listing) => `${listing.id}: ${listing.address}, ${compactPrice(listing.price)}`)
      .join("; ")}.`,
  ].join("\n")}
/>

Load details with an app-only tool

get-listing-details has visibility: "app", so MCP Apps hosts hide it from the model while the view can still call it. The view calls it with useCallTool() when the user selects a card or pin:

src/index.ts
export const getListingDetails = server.tool(
  {
    name: "get-listing-details",
    visibility: "app",
    inputSchema: z.object({ id: z.string() }),
    // ...
  },
  async ({ id }) => {
    // Look up the listing and return its staged details.
  }
);
views/property-search/view.tsx
const detailsTool = useCallTool("get-listing-details");
// On card or pin selection:
void detailsTool.callTool({ id });

Allow map tiles and fullscreen

The view loads tiles from server.arcgisonline.com, so the tool's view.csp lists that origin in resourceDomains and connectDomains. Without it, the host's Content Security Policy blocks the tiles and the map renders blank.

The view declares both display modes in viewConfig and requests fullscreen from a button with useDisplayMode():

views/property-search/view.tsx
export const viewConfig: ViewConfig = {
  autoResize: true,
  displayModes: ["inline", "fullscreen"],
};
 
// Inside the view component:
const { displayMode, availableDisplayModes, requestDisplayMode } = useDisplayMode();
const canFullscreen = availableDisplayModes.includes("fullscreen");
// On button click:
void requestDisplayMode({ mode: "fullscreen" });

Adapt it to your app

  • Real listings: replace the staged LISTINGS array with a call to your listing API inside search-homes. Keep the returned catalog small. For large datasets, return only the first page and fetch more from the view with an app-only tool.
  • Another tile provider: change the tile URL in map.tsx, update both CSP lists, and show the attribution your provider's terms require.
  • Other domains: the same shape fits any "search, then refine" app, such as hotels, restaurants, or job listings. Keep one rendering tool, move refinements into view tools, and report visible state with ModelContext.

See the complete property search example for the runnable project, and Interactivity for more on view tools, app-only tools, and follow-up messages.

FAQ

About the cookbook, mcp-use, and Manufact Cloud.

What is the difference between mcp-use and Manufact?

mcp-use is our open-source TypeScript framework for building MCP servers and apps. Manufact is the company behind mcp-use. Manufact Cloud is our managed platform for deploying and operating MCP servers. Most cookbook recipes use mcp-use.

Do I need Manufact Cloud to use these recipes?

No. You can run mcp-use locally or host it on your own infrastructure. Follow each recipe's runtime requirements and setup instructions. Any third-party services used in a recipe may require their own accounts or API keys. Manufact Cloud is an optional hosting choice.

Share