Our MCP server at https://shopwithin.co/mcp exposes 6 tools, and none of them can reserve, hold or charge anything. An agent asks what is in stock at independent boutiques. It gets back pieces, sizes, prices and the product page for each one.
We read every number below from production on 27 September 2026.
Quick facts
-
Endpoint: Streamable HTTP, stateless, no account needed; the handshake echoes any protocol version the SDK knows and answers
2025-11-25to any other - Tools: 6, every one annotated as read only, idempotent and closed world
- Resources: 3 listed, plus 1 template that returns a journal guide as markdown
- Shelves: 2 boutiques live, holding 3,699 and 930 pieces
- Pages: up to 24 pieces a call, with offsets up to 480
- Photographs: visual search takes images under 1.5 MB and never stores them
One server per request
The endpoint is a route in our React Router app on Vercel. Each POST builds a fresh McpServer and transport, answers one message, then throws both away.
The Streamable HTTP transport makes sessions optional. The SDK has a stateless mode for exactly this shape, so no session store has to live between function invocations.
// consumer-web/app/routes/mcp.tsx, trimmed
async function serve(request: Request): Promise<Response> {
const server = createWithinMcpServer();
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless: no session ids
enableJsonResponse: true, // one JSON body, no event stream
});
await server.connect(transport);
try {
return withCors(await transport.handleRequest(request));
} finally {
// the JSON body is complete here, so closing never cuts an answer
void transport.close().catch(() => undefined);
}
}
A plain GET returns a small JSON descriptor with the tool names, for a person with curl. DELETE answers 405, since a stateless server has no session to end. A token bucket per IP, with a global ceiling, limits the calls.
Six tools, named for the shopper
A model chooses a tool from its description, so ours say when to call it, in a shopper's words.
-
search_piecesfinds pieces from a plain sentence, or from brands, categories, sizes, boutiques, a city and a price range. -
get_piecereads one piece in full, with every size and the units in stock. -
similar_piecesreturns alternatives to an anchor piece across every boutique. -
list_boutiquesnames the shops by city, with pieces live and how soon a piece is ready to collect. -
boutique_shelfreads one shop's shelf, newest first, with an optional search inside it. -
visual_searchmatches a photograph, a chat upload, or a TikTok or Pinterest link.
A piece is boutique_domain plus id across the tools, so a model chains a search into get_piece without parsing a URL. One registration, trimmed:
// consumer-web/app/lib/mcp/server.server.ts, trimmed
server.registerTool(
"boutique_shelf",
{
title: "One boutique's shelf",
description:
"One boutique's shelf right now, newest first, with sizes and prices; " +
"a query searches inside that shop only. Use this when a shopper names a specific boutique...",
inputSchema: {
boutique_domain: z.string().max(120),
query: z.string().max(200).optional(),
limit: z.number().int().min(1).max(24).optional(),
offset: z.number().int().min(0).max(480).optional(),
},
outputSchema: {
boutique: BoutiqueSchema.nullable(),
pieces: z.array(PieceSchema),
next_offset: z.number().nullable(),
view_url: z.string(),
},
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, idempotentHint: true },
// the ChatGPT widget keys, covered below
_meta: shelfMeta("Reading the boutique's shelf", "Read the boutique's shelf"),
},
async (args) => timed("boutique_shelf", async () => {
// read the feed, shape each piece, return { content, structuredContent }
}),
);
The server instructions, sent back in the handshake, include this line: "never state availability, sizes, or prices that a tool did not return." The search_pieces description ends: "Do not use for general style advice or for mass market retailers."
How a model asks what a boutique has
This is a real call against production, run on 27 September 2026. It asks WDLT117's shelf for MM6 pieces under $300.
$ curl -s https://shopwithin.co/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"boutique_shelf",
"arguments":{"boutique_domain":"wdlt117.myshopify.com","query":"MM6 under $300","limit":2}}}' \
| jq -r '.result.content[0].text'
WDLT117 (Toronto), pieces matching "MM6":
1. MM6 Maison Margiela MM6 X Dr. Martens Edition 8-eye Burgundy, CAD 249, sizes in stock: UK 3 / EU 36, at WDLT117 (Toronto). https://shopwithin.co/product/wdlt117/mm6-maison-margiela-mm6-x-dr-martens-edition-8-eye-burgundy/10083805495606
Stock checked 17 days ago.
More: call again with offset 2.
The boutique on within: https://shopwithin.co/store/wdlt117
The server keeps nothing between requests, so this bare tools/call works with no initialize first. That makes curl a fair test client.
The query is a plain sentence. It runs through the same parser as the site's search box, so "under $300" became a price ceiling and "MM6" stayed the search.
Every answer has two halves. The content text is one line per piece that a model can quote whole: designer, price with its currency, sizes in stock, the shop and the URL. The structuredContent half is typed and checked against the tool's zod outputSchema, and it is what the ChatGPT widget reads.
For the boot, the typed half held price: 249, currency: "CAD" and units_in_stock: 1. Its condition came back null: the listing never stated one, and we do not guess.
Its stock_checked_at read 10 September 2026, and the text half printed that as "Stock checked 17 days ago." Printing an age lets a model hedge instead of promising stock.
Sizes are the hard part. One register labels a shoe 10M, another writes UK 3 / EU 36, and the engine matches labels exactly. When a size finds nothing, search_pieces reads a wider page and lets a bare number match that number in any system.
Asked for a size 10 Jordan 4 under $500, search_pieces returned a pair at CAD 500 with 10M on the shelf. The note said no conversion was implied.
What we refuse
A refusal should say what happened and what to try next. These three came back from production:
- A city with no shops: "within has no boutiques live in Paris yet. Boutiques are live in Toronto; the same search without a city reads every shelf."
- A piece that has gone: "It may have sold, or the link may be old. A fresh search shows what is in stock today."
- An Instagram post: "Instagram does not allow its photographs to be read; ask the shopper for a screenshot and pass it as image_base64."
Pieces from demo shops, and pieces without a photograph, never leave the server. visual_search reads a photograph in memory and never stores it.
There is no write tool at all. The shopper opens the piece's page and buys there. If we ever add holds, they get separate tools with their own consent step.
The ChatGPT shelf
Five of the six tools carry openai/outputTemplate, pointing at ui://widget/within-shelf.html. That text/html+skybridge resource renders the pieces as a grid of photo tiles. A client that does not read the openai/ keys still gets both halves, as plain text and JSON.
The widget's image policy allows a fixed list of 9 host patterns: the Shopify, Squarespace, Wix, Square and Lightspeed image hosts, plus our own. A WooCommerce boutique serving images from its own domain would get tiles with no photograph until we build that list from the live stores.
What we still get wrong
The size example above asked for "Jordan 4 under $500, cheapest first". The parser lifted "cheapest" into a price sort but left "first" in the query. The answer opened "No exact match; the closest by name", yet it still found the pair.
The note gave the bug away: Understood as: searched for "Jordan 4, first", priced up to 500, lowest price first. Echoing the parse back shows the model, and us, exactly how a sentence was read.
Pages come back short. Our search engine filters price by coarse bands, and the band that holds $300 runs to $400. We drop the pieces between $300 and $400 after the engine has already cut the page.
On WDLT117 the next two MM6 pieces in that band were a CAD 340 bucket hat and a CAD 345 beanie. So the call above asked for 2 and showed 1. Asked for 24, the same shelf shows 3.
Asked for 1 at offset 1, it says it has nothing matching "MM6", then offers offset 3. The code reads one more window when a page comes back empty, and here that was not enough.
A higher ceiling returned fewer pieces. With a limit of 3, "MM6 under $399" showed the boot, the hat and the beanie, and "MM6 under $401" showed only the boot.
At $401 the ask covers every band, so the code sends no price filter at all. The rest of the window is pieces over $401, and our filter throws them away.
Related searches read like catalog tags. For "black leather boots" they came back as cotton (80), relaxed (62) and Pants (45), which helps nobody shopping for boots. The Jordan 4 search got nearly the same list.
The freshness line is wrong in both directions. It prints the newest check on the page, so the 24 piece answer said "Stock checked 3 days ago" over the boot's 17 day old stamp.
The stamp itself is the last time our sync rewrote the product record. The nightly sync skips unchanged products, and a stock webhook updates the count without touching that record, so a count can be newer than its date.
Analytics are thin. Each tool call logs the tool, the milliseconds and the client's name, and in stateless mode that name always reads unknown.
A client names itself once, in the initialize request. Every tools/call lands on a fresh server that never heard it.
If you build one
- Build the server per request. Stateless mode means no session store on a serverless host.
- Write each tool description as a rule for when to call it.
- Return a line a model can quote and a typed object a widget can render.
- Make every refusal one plain sentence with the next step in it.
- Echo back how you read the question.
- Print how old the data is, show the oldest stamp on a page, and name each stamp after what writes it.
- Filter before you cut a page, or keep reading until the page is full.
- Expect nothing from
initializeon a stateless server, because each call starts from zero.
With no session and no account, a client needs only the URL. claude mcp add --transport http within https://shopwithin.co/mcp connects Claude Code, and other Streamable HTTP clients take the same one.
Top comments (0)