DEV Community

Birinchishop
Birinchishop

Posted on

How we built an MCP App for a cash-on-delivery store in Uzbekistan

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: true is 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'), ... }
Enter fullscreen mode Exit fullscreen mode

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 },
    },
  }] });
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

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'] },
});
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)