HotelFlow tutorial
4. MCP-backed hotel search
ExploreStep accepts validated conversational criteria, calls a real local MCP pricing service, and keeps durable state and routing inside PicoFlow.
capture_choices is where the HotelFlow conversation becomes an application
operation. The model submits one accumulated search request; ExploreStep
validates and persists it, calls the MCP service, and decides whether the flow
advances or stays active.
The boundary
model -> capture_choices -> ExploreStep handler -> MCP client
-> search_hotels MCP service -> PricingEngine -> route decision
The model does not call MCP directly. PicoFlow owns the conversation-specific
tool, the durable ExploreStep.state.json payload, and the go(...)/stay(...)
decision. The MCP service owns its typed, read-only search operation. This
division means an empty search remains a normal workflow result while a service
failure is not misreported as “no hotels found.”
A typed capture tool
The model no longer serializes a whole JSON object inside a string. It submits a
typed criteria object instead:
{
name: "capture_choices",
description:
"Submit the complete accumulated hotel search criteria after the user asks to search.",
schema: z.object({
criteria: HotelSearchCriteriaSchema.describe(
"The complete hotel search criteria accumulated from the conversation.",
),
}),
}
HotelSearchCriteriaSchema requires the selected amenities and room types,
min/max budget, distance constraints, and start/end dates. The prompt still
collects those values over several turns, but schema validation now happens
before application code reads nested fields.
The ExploreStep handler
const parsedChoices = HotelSearchCriteriaSchema.safeParse(args?.criteria);
if (!parsedChoices.success) {
return stay("The hotel search criteria are incomplete or invalid. Collect valid dates, preferences, and distances before searching.");
}
const choices = parsedChoices.data;
const startDate = new Date(choices.cDate.start);
const endDate = new Date(choices.cDate.end);
if (Number.isNaN(startDate.getTime()) ||
Number.isNaN(endDate.getTime()) ||
endDate <= startDate) {
return stay("The checkout date must be after a valid check-in date. Ask the user to correct their stay dates.");
}
this.saveState({ json: JSON.parse(JSON.stringify(choices)) });
let hotelEntries;
try {
hotelEntries = await searchHotelsViaMcp(toHotelPricingSearchRequest(choices));
} catch {
return stay("Hotel pricing is temporarily unavailable. Ask the user to try the search again.");
}
Validation and date ordering are business rules in code, not hopes encoded in
prompt prose. The JSON round trip before saveState makes the persisted value
explicitly compatible with PicoFlow’s durable JSON state.
When results exist, the handler projects exactly the view that PresentStep
needs and transfers ownership with go(PresentStep).withState(...). When the
array is empty it returns the existing no-match stay(...) path.
The MCP contract and service
The shared Zod contract maps the conversational payload to a compact service request:
{
startDate: string,
endDate: string,
amenities: string[],
roomTypes: string[],
budget: { min: number | null, max: number | null },
maxDistanceMiles: { airport: number | null, cityCenter: number | null }
}
The stdio server registers the read-only search_hotels tool with both input
and output schemas:
server.registerTool("search_hotels", {
title: "Search Portland hotels",
inputSchema: HotelPricingSearchRequestSchema,
outputSchema: HotelPricingSearchResponseSchema,
annotations: { readOnlyHint: true, idempotentHint: true },
}, async (request) => {
const hotels = await PricingEngine.searchHotel(/* mapped request fields */);
return {
structuredContent: { hotels },
content: [{ type: "text", text: JSON.stringify({ hotels }) }],
};
});
structuredContent is the machine-readable result used by the client; the text
block keeps the response compatible with clients that only consume text. The
client keeps a connection to the local stdio child process, enforces a timeout,
checks MCP tool errors, and validates returned structured content before
returning hotel rows to the step. Nest shutdown closes that client cleanly.
Run the service independently when inspecting it with an MCP client:
npm run mcp:hotel-pricing
The application normally starts and owns the child process lazily through the adapter, so there is no second terminal to run for HotelFlow.
Pricing remains plain TypeScript
The MCP server is an adapter, not a rewrite of the domain logic.
PricingEngine.searchHotel(...) still filters the bundled Portland catalog,
enumerates the stay, applies season/holiday/room/weekend multipliers, filters
by nightly budget, and returns { hotelName, prices, total } rows.
HotelCatalog and PricingEngine still have no PicoFlow or MCP imports. That
makes the service boundary replaceable: a future server can call live inventory
without changing ExploreStep’s state/routing contract.
The holiday fixture compares only month and day, so its 2025 floating-holiday dates recur in other years. A real booking system needs a year-aware calendar, current inventory, and reviewed pricing rules.
Test the boundary
npm run test:hotel-pricing-mcp
The suite verifies the advertised MCP tool and schemas in memory, invalid input handling, and a real local stdio client-to-service call. The HotelFlow scenario remains the model-driven test of criteria collection and downstream routing.
Next
5. Memory compaction and erasure looks at the conversation history that produced this tool call.