By the Birinchi Shop team
In Uzbekistan most online shopping still ends with cash. You pick a product, leave your name and phone number, a call-center operator phones you to confirm, and a courier brings the parcel 1–3 days later. You inspect it at the door and pay the courier in cash, or refuse it.
Birinchi Shop works exactly like that, with 300+ products and delivery to every region of the country. Recently we gave the store a public MCP server, so an AI assistant can search the catalog and place an order for a shopper. This week we turned it into an MCP App. Instead of a wall of JSON-ish text, the assistant now shows real product cards, a variant picker and an order form, right inside the chat.
This post covers how it is built and what broke when we first tried it in a real host.
The starting point: a small, stateless MCP server
Our shop server is deliberately boring. It runs on Express, with no SDK: one POST endpoint that speaks JSON-RPC 2.0 over Streamable HTTP. It keeps no sessions, needs no API key and uses no SSE stream. It exposes five tools:
| Tool | What it does |
|---|---|
search_products |
keyword search over the catalog (Uzbek/Russian names), price filters |
get_product |
images, video, variants with their own price and stock, total to pay |
store_info |
delivery area, fee, cash-on-delivery, returns |
place_order |
places a real order that a call center will phone |
get_order_status |
needs the order id and the last 4 digits of the phone |
place_order is the dangerous one, because every order costs a real operator's phone call. So the server enforces rules no matter what the model does:
-
customer_confirmed: trueis required. Without it the call is rejected with a message telling the agent to ask the shopper first. - The phone must be a valid Uzbek mobile number.
- The same limits as our website apply: one order per phone per product per 24 hours, at most three different products.
- There is a per-IP hourly cap and a daily cap for the whole AI channel.
- Every AI order is flagged for the admins.
Cash on delivery helps a lot here. Even if something goes wrong, nobody gets charged. A bad order costs us one phone call.
What an MCP App adds
The MCP Apps extension (io.modelcontextprotocol/ui) lets a tool point at an HTML resource. A host that supports it renders that HTML in a sandboxed iframe next to the tool result. The view and the host talk JSON-RPC over postMessage.
There are three pieces on the server side.
1. Tools point at a ui:// resource
const APP_URI = 'ui://birinchi-shop/store-v4.html';
const uiMeta = (invoking, invoked) => ({
ui: { resourceUri: APP_URI }, // MCP Apps standard
'openai/outputTemplate': 'ui://birinchi-shop/store-v4.skybridge.html', // ChatGPT Apps SDK
'openai/widgetAccessible': true,
'openai/toolInvocation/invoking': invoking,
'openai/toolInvocation/invoked': invoked,
});
// in the tool list:
{ name: 'search_products', _meta: uiMeta('Searching the store…', 'Products found'), ... }
Four of the five tools share one view: search, product, order and status. store_info stays text-only.
2. The resource itself
case 'resources/read': {
const r = APP_RESOURCES.find(x => x.uri === msg.params?.uri);
if (!r) return rpcError(msg.id, -32002, 'Resource not found');
return rpcResult(msg.id, { contents: [{
uri: r.uri,
mimeType: r.mimeType, // 'text/html;profile=mcp-app'
text: readAppHtml(),
_meta: {
ui: { csp: { resourceDomains: ['https://birinchishop.uz'], connectDomains: [] },
prefersBorder: false },
},
}] });
}
The CSP is tight. The view may load images and video from our own domain and nothing else, and it makes no network requests at all. Every call to the server goes through the host as tools/call.
3. The text result does not change
The tool still returns content (text) and structuredContent (JSON). Clients without UI support keep working exactly as before. The view just reads structuredContent.
One HTML file, no build step
The view is a single ~45 KB HTML file with inline CSS and JS: no framework, no bundler, no CDN. It works out what to render from the shape of the result:
function showResult(res) {
const sc = res.structuredContent;
if (res.isError || !sc) return showError(res);
if (Array.isArray(sc.items)) return showList(sc); // search_products
if (sc.total_to_pay && Array.isArray(sc.variants)) return openProduct(sc); // get_product
if ('next_steps' in sc && 'order_id' in sc) return showDone(sc); // place_order
if (sc.status_text) return showStatus(sc); // get_order_status
}
Product names come from our database and some of them are messy, so all text goes into the DOM through textContent and a tiny h(tag, attrs, ...children) helper. There is no innerHTML anywhere.
The bridge itself is small: send ui/initialize, store hostContext (theme, CSS variables, locale, display mode), send ui/notifications/initialized, then listen for ui/notifications/tool-result. A ResizeObserver reports ui/notifications/size-changed, so the iframe grows when the shopper opens the order form.
Tapping a card calls get_product from the view. The model is not involved, so browsing is instant and costs no tokens.
Consent belongs in the UI
In the text-only version the model had to collect name, phone and region in chat, read the total back and get a "yes". That works, but it is easy to get wrong, and a confirmation the model typed for the shopper is still just text.
In the app, the shopper fills in the form themselves. The next screen repeats the product, variant, name, phone, region and the cash total, and has one button: "I confirm — place order". Only that tap sends place_order with customer_confirmed: true. The server-side rules still apply on top.
After a successful order the view calls ui/update-model-context, so the assistant knows the order already exists and does not place it again:
bridge.updateContext(
`The shopper placed order #${o.order_id} in the store app ... Do not place this order again.`,
{ order_id: o.order_id, status: o.status }
);
get_order_status renders as a simple timeline: received → operator confirms → with the courier → delivered, paid in cash.
Four bugs that only showed up in the real host
Everything passed in our local harness, a headless Chrome page that iframes the view and plays the host. Then we opened it on a phone in Claude.
1. clientInfo vs appInfo
The first live run showed our fallback error screen. We had written the ui/initialize params from memory, MCP-style, with clientInfo. The view protocol uses appInfo:
await request('ui/initialize', {
protocolVersion: '2026-01-26',
appInfo: { name: 'birinchi-shop-app', version: '1.1.0' }, // not clientInfo
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
});
Our harness answered any ui/initialize; the real host validated the params. Lesson: copy the request shape from the SDK's types (spec.types.ts in the ext-apps repo), and make the error screen print the actual reason. We now show it in brackets, so a screenshot is enough to debug.
2. A white card in a dark chat
Next, the view rendered in a dark chat with a white background and washed-out green text. The host passes theme variables as light-dark(#fff, #262624). light-dark() resolves from the element's color-scheme, and we never set one, so it always picked the light value. Meanwhile our own accent colors followed data-theme="dark", hence the odd mix.
3. ...and then a black frame around it
The fix for #2 was color-scheme: dark on :root. That made things worse in a different way. When an iframe's root color-scheme differs from its embedding page, the browser paints the iframe with an opaque canvas background, and a dark rectangle appeared around our card. The final version sets color-scheme on the app container instead of the root, so the iframe stays transparent and the host variables resolve correctly:
#app { color-scheme: light; color: var(--fg); }
:root[data-theme="dark"] #app { color-scheme: dark; }
4. Hosts cache the resource
After every HTML change, the phone kept showing the old version. Hosts cache ui:// resources, so we now put a version in the URI (store-v4.html) and bump it with every change to the view. Reconnecting the connector refreshes the tool list.
A bonus trap, specific to our setup: the Puppeteer in our frontend was so old that its bundled Chromium could not parse optional chaining (?.). The harness has to use a current Chrome, or it reports syntax errors no real user will ever see.
Testing without creating orders
Every real place_order rings a real phone, so the server accepts one reserved reviewer number. That number runs all the validation (product exists, in stock, variant chosen, valid phone) but returns test: true and never creates an order. The view shows a "Test order" screen for it. Directory reviewers get the number in our submission notes, and it is how we test on production.
The harness itself is under 100 lines: a page with an iframe, a fake host that answers ui/initialize, forwards tools/call to the real server handler, and resizes the iframe on size-changed. A script then clicks through search → product → variant → form → confirm and takes screenshots in light and dark mode.
What we would tell someone starting today
- Keep the text result first-class. The UI is an extra layer; plenty of clients will never render it.
- Let the view call your tools directly for browsing. It is faster and keeps the model out of simple clicks.
- Put irreversible actions behind a button the user taps, and still enforce the rules on the server.
-
Lock the CSP down and route all data through
tools/call. - Version your
ui://URIs. - Test in a real host early, on a phone, in dark mode.
If you want to see it working, add our store server as a custom connector in an MCP client that supports apps:
https://birinchishop.uz/api/shop/mcp
No API key is needed. Ask for something like "find a watch in Birinchi Shop". The usage guide is at birinchishop.uz/en/ai-shopping.
Questions about the implementation are welcome in the comments.





Top comments (0)