<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Birinchishop</title>
    <description>The latest articles on DEV Community by Birinchishop (@birinchishop).</description>
    <link>https://dev.to/birinchishop</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4210854%2F473588db-b57b-4908-850c-c8722a14939d.png</url>
      <title>DEV Community: Birinchishop</title>
      <link>https://dev.to/birinchishop</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/birinchishop"/>
    <language>en</language>
    <item>
      <title>How we let AI assistants manage an affiliate account without letting them spend the money</title>
      <dc:creator>Birinchishop</dc:creator>
      <pubDate>Tue, 27 Oct 2026 12:37:35 +0000</pubDate>
      <link>https://dev.to/birinchishop/how-we-let-ai-assistants-manage-an-affiliate-account-without-letting-them-spend-the-money-2ke3</link>
      <guid>https://dev.to/birinchishop/how-we-let-ai-assistants-manage-an-affiliate-account-without-letting-them-spend-the-money-2ke3</guid>
      <description>&lt;p&gt;&lt;em&gt;By the Birinchi Shop team&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our earlier post was about the &lt;a href="https://dev.to/birinchishop/how-we-built-an-mcp-app-for-a-cash-on-delivery-store-in-uzbekistan-4gin"&gt;shopper side of Birinchi Shop&lt;/a&gt;: an AI assistant that finds a product and places a cash-on-delivery order. This one is about the other side, the people who bring those orders in.&lt;/p&gt;

&lt;p&gt;Birinchi Shop is a &lt;strong&gt;CPA (cost-per-action) network&lt;/strong&gt; in Uzbekistan. Partners (affiliates, media buyers, Telegram channel owners) pick a product, get a tracking link and promote it. A customer orders on the landing page, our call center phones them to confirm, a courier delivers, and the customer pays cash. The partner earns a fixed payout &lt;strong&gt;only when the order is delivered&lt;/strong&gt;. Payouts depend on the product, typically from 30,000 to 100,000+ UZS. Income is not guaranteed; it depends entirely on the traffic.&lt;/p&gt;

&lt;p&gt;That gives a partner a lot to watch: which offers sell, which links convert, why orders get stuck at "operator is calling", and whether a Meta ad campaign is burning money. So we gave them a public API and an MCP server. A partner can now connect Claude or ChatGPT to their account and ask "how did I do yesterday, and what should I change?".&lt;/p&gt;

&lt;p&gt;The hard part was not the tools. It was deciding &lt;strong&gt;what an AI is allowed to do on its own&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One business layer, two front doors
&lt;/h2&gt;

&lt;p&gt;Everything lives under &lt;code&gt;/api/v1&lt;/code&gt;, rate-limited to 120 requests per minute per key:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;REST API&lt;/strong&gt; with an OpenAPI 3.1 spec and a reference page;&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;MCP server&lt;/strong&gt; (&lt;code&gt;/api/v1/mcp&lt;/code&gt;, stateless Streamable HTTP) for AI clients;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;webhooks and postbacks&lt;/strong&gt; for order status changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;REST and MCP call the same service functions. An MCP tool is a thin wrapper that declares the scope it needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;list_orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders:read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;readOnlyHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;List orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* status, date range, link, limit */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rule kept us honest. Every permission check, rate limit and response filter exists once, and the AI path cannot quietly get more access than the REST path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the assistant can do
&lt;/h2&gt;

&lt;p&gt;18 tools, grouped by scope:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;account:read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_account&lt;/code&gt; (balance, payout status)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;offers:read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;search_offers&lt;/code&gt;, &lt;code&gt;get_offer&lt;/code&gt; (payout, sale rate, stock, the partner's existing links)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;links:read&lt;/code&gt; / &lt;code&gt;links:write&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;list_links&lt;/code&gt;, &lt;code&gt;create_link&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;orders:read&lt;/code&gt; / &lt;code&gt;orders:write&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;list_orders&lt;/code&gt;, &lt;code&gt;get_order&lt;/code&gt;, &lt;code&gt;create_order&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;stats:read&lt;/code&gt;, &lt;code&gt;events:read&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_stats&lt;/code&gt;, &lt;code&gt;list_events&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;insights:read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_insights&lt;/code&gt; (yesterday + recommendations), &lt;code&gt;get_order_sale_chance&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ads:read&lt;/code&gt; / &lt;code&gt;ads:write&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;list_campaigns&lt;/code&gt;, &lt;code&gt;get_ads_status&lt;/code&gt;, &lt;code&gt;get_action&lt;/code&gt;, &lt;code&gt;pause_campaign&lt;/code&gt;, &lt;code&gt;resume_campaign&lt;/code&gt;, &lt;code&gt;change_campaign_budget&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There are also four MCP &lt;strong&gt;prompts&lt;/strong&gt; that show up in the client's &lt;code&gt;/&lt;/code&gt; menu: &lt;em&gt;Daily review&lt;/em&gt;, &lt;em&gt;Find an offer to promote&lt;/em&gt;, &lt;em&gt;Check my ad campaigns&lt;/em&gt; and &lt;em&gt;Why are my orders not delivered?&lt;/em&gt;. Most partners are not developers; one tap is better than a well-written prompt they will never write.&lt;/p&gt;

&lt;p&gt;The server instructions teach the model one piece of domain knowledge that matters more than any other:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;sale_rate_percent is the share of finished orders that end up delivered. It matters more than payout: a 30,000 payout at 40% beats a 70,000 payout at 5%.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without that line, models happily recommend the offer with the biggest number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: read-only by default
&lt;/h2&gt;

&lt;p&gt;Partners connect through &lt;strong&gt;OAuth 2.1&lt;/strong&gt;, no copy-pasting keys. The consent screen lists the scopes in plain language, and if the client asks for nothing specific, it gets only the read scopes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_SCOPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account:read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offers:read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;links:read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders:read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stats:read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;events:read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insights:read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ads:read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write scopes must be requested and approved explicitly. Webhook management is never available over OAuth at all; that stays with API keys created in the panel.&lt;/p&gt;

&lt;p&gt;Implementation notes, for those building the same thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PKCE with &lt;strong&gt;S256 only&lt;/strong&gt;; authorization codes are single-use and live 5 minutes.&lt;/li&gt;
&lt;li&gt;Access tokens live 1 hour, refresh tokens 30 days and rotate on every use.&lt;/li&gt;
&lt;li&gt;Claude.ai registers itself via a client metadata document (its &lt;code&gt;client_id&lt;/code&gt; is a URL), so we support that in addition to dynamic registration.&lt;/li&gt;
&lt;li&gt;Every connected app is stored like an API key. Revoking it in the panel, or changing the password, kills its tokens.&lt;/li&gt;
&lt;li&gt;When an app connects, the partner gets a Telegram message: &lt;em&gt;"New app connected: … If this wasn't you, revoke it now."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rule 2: writes that cost other people's time are earned
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;create_order&lt;/code&gt; sends a lead to a real call center. A brand-new account with an AI attached could flood it with junk. So the scope alone is not enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ORDERS_WRITE_MIN_DELIVERED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// orders:write works only after the partner has 3 delivered orders,&lt;/span&gt;
&lt;span class="c1"&gt;// or an admin has enabled it manually&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating a tracking link, on the other hand, is cheap and reversible, so &lt;code&gt;links:write&lt;/code&gt; has no such gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: stopping spend is instant, increasing it needs a human
&lt;/h2&gt;

&lt;p&gt;This is the rule we like most. Meta ad campaigns spend the partner's own money, so the three ad actions are deliberately asymmetric:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pause_campaign&lt;/code&gt;&lt;/strong&gt; runs immediately. Stopping spend is always safe, and when a campaign is clearly losing money, waiting is the expensive option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;resume_campaign&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;change_campaign_budget&lt;/code&gt;&lt;/strong&gt; do &lt;strong&gt;not&lt;/strong&gt; act. They create a confirmation card in the partner's Telegram bot and panel and return:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;412&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending_confirmation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confirmation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"where"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"telegram_bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"panel"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The partner must press &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Confirm&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;. Poll get_action for the result."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model is told to say "please press Confirm" rather than "done", and it can check the outcome later with &lt;code&gt;get_action&lt;/code&gt; (pending, running, done, failed, cancelled, expired).&lt;/p&gt;

&lt;p&gt;The confirmation happens &lt;strong&gt;outside the AI client&lt;/strong&gt;, in a channel the partner already trusts. A prompt injection hidden in a product description or an ad comment can at most &lt;em&gt;ask&lt;/em&gt; for a budget increase, and that ask lands on the partner's phone with the exact amount on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: the API decides what leaves the server
&lt;/h2&gt;

&lt;p&gt;Two kinds of data never go out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Customer phone numbers&lt;/strong&gt; are always masked. Partners see the last digits, enough to match an order, never the full number. The AI sees exactly the same.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal supply-side details&lt;/strong&gt;, such as which supplier fulfils an order or their internal error messages, are not part of the partner's world.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We enforce both with &lt;strong&gt;allowlist serializers&lt;/strong&gt;. Every response object is built field by field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;STATUS_PUBLIC&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;conversion_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONVERSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lead&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// lead / approved / sale / rejected&lt;/span&gt;
    &lt;span class="na"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientFullName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;phone_masked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;maskCustomerPhone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerPhone&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;regionLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;payout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;num&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;commissionAmount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;payout_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;payoutStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;// ...and nothing that wasn't written here on purpose&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a column to the database and it does not appear in the API until someone types it here. That is slower to develop and exactly what we want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks without surprises
&lt;/h2&gt;

&lt;p&gt;Partners who track conversions in their own tools get webhooks and postbacks on order status changes. Two details save a lot of support tickets:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Events come from the database, not the code.&lt;/strong&gt; A trigger on the &lt;code&gt;orders&lt;/code&gt; table writes every status change into an &lt;code&gt;api_events&lt;/code&gt; table, so it does not matter which part of the system changed the status: the call-center sync, an admin, a cron job. Webhook delivery reads that table with a cursor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every delivery is signed:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-Birinchishop-Event: order.status_changed
X-Birinchishop-Event-Id: evt_10293
X-Birinchishop-Signature: t=1761580800,v1=&amp;lt;hex HMAC-SHA256(secret, "&amp;lt;t&amp;gt;.&amp;lt;body&amp;gt;")&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Outgoing requests go through an SSRF-safe HTTP client: the target host is resolved first and refused if any address is private, loopback or cloud metadata, and redirects are never followed. "Enter your URL" is an invitation to point us at internal services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bugs worth sharing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The database lives in Europe/Berlin.&lt;/strong&gt; Our Postgres server's time zone is Berlin; Prisma reads and writes UTC. Anything written by a trigger with plain &lt;code&gt;now()&lt;/code&gt; came out one or two hours off and broke "events since" queries. Triggers now use &lt;code&gt;now() AT TIME ZONE 'utc'&lt;/code&gt;. If you mix ORM writes and SQL triggers, check this first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool descriptions are not a place for instructions.&lt;/strong&gt; On our store server, the first descriptions said things like "Call ONLY after…" and "Never use…". They read as rules to the model, but for a connector directory they look like a tool trying to steer the model. We rewrote them as plain facts about what each tool does, and moved behavior guidance into the server &lt;code&gt;instructions&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every client can do OAuth headers the way you expect.&lt;/strong&gt; One MCP directory detected our OAuth metadata and silently dropped the custom &lt;code&gt;Authorization&lt;/code&gt; header from its health check. Plain API keys in an &lt;code&gt;X-API-Key&lt;/code&gt; header remain supported for exactly this kind of client.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is for
&lt;/h2&gt;

&lt;p&gt;We designed the tools around questions partners already ask our support team, not around automation. &lt;em&gt;Why are my orders still at "callback"?&lt;/em&gt; The model can call &lt;code&gt;list_orders&lt;/code&gt;, see which customers the call center could not reach, and use &lt;code&gt;get_order_sale_chance&lt;/code&gt; to show which of them have a history of refusing deliveries. &lt;em&gt;Which offer should I run this week?&lt;/em&gt; It can compare sale rates and stock with &lt;code&gt;search_offers&lt;/code&gt; instead of just picking the biggest payout.&lt;/p&gt;

&lt;p&gt;An autopilot was never the goal. The goal was a colleague who reads the dashboard for the partner and has to ask before spending their money.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; the docs and OpenAPI spec are at &lt;a href="https://birinchishop.uz/reference?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=partner_api" rel="noopener noreferrer"&gt;birinchishop.uz/reference&lt;/a&gt;, and an LLM-friendly guide is at &lt;a href="https://birinchishop.uz/api/v1/guide.md" rel="noopener noreferrer"&gt;/api/v1/guide.md&lt;/a&gt;. The MCP server URL for Claude or ChatGPT custom connectors is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://birinchishop.uz/api/v1/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs a partner account. If you run traffic in Uzbekistan, you can &lt;a href="https://birinchishop.uz/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=partner_api" rel="noopener noreferrer"&gt;sign up here&lt;/a&gt;. Earnings depend on your traffic and are not guaranteed.&lt;/p&gt;

&lt;p&gt;Questions about the OAuth flow or the confirmation design are welcome in the comments.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>security</category>
      <category>oauth</category>
      <category>api</category>
    </item>
    <item>
      <title>How we built an MCP App for a cash-on-delivery store in Uzbekistan</title>
      <dc:creator>Birinchishop</dc:creator>
      <pubDate>Tue, 27 Oct 2026 12:25:56 +0000</pubDate>
      <link>https://dev.to/birinchishop/how-we-built-an-mcp-app-for-a-cash-on-delivery-store-in-uzbekistan-4gin</link>
      <guid>https://dev.to/birinchishop/how-we-built-an-mcp-app-for-a-cash-on-delivery-store-in-uzbekistan-4gin</guid>
      <description>&lt;p&gt;&lt;em&gt;By the Birinchi Shop team&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://birinchishop.uz/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mcp_app" rel="noopener noreferrer"&gt;Birinchi Shop&lt;/a&gt; works exactly like that, with 300+ products and delivery to every region of the country. Recently we gave the store a public &lt;strong&gt;MCP server&lt;/strong&gt;, so an AI assistant can search the catalog and place an order for a shopper. This week we turned it into an &lt;strong&gt;MCP App&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;This post covers how it is built and what broke when we first tried it in a real host.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftcmm9713v84jgihc7wg5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftcmm9713v84jgihc7wg5.png" alt=" " width="800" height="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point: a small, stateless MCP server
&lt;/h2&gt;

&lt;p&gt;Our shop server is deliberately boring. It runs on Express, with no SDK: one &lt;code&gt;POST&lt;/code&gt; 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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search_products&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;keyword search over the catalog (Uzbek/Russian names), price filters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_product&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;images, video, variants with their own price and stock, total to pay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;store_info&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;delivery area, fee, cash-on-delivery, returns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;place_order&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;places a &lt;strong&gt;real&lt;/strong&gt; order that a call center will phone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_order_status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;needs the order id &lt;strong&gt;and&lt;/strong&gt; the last 4 digits of the phone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;place_order&lt;/code&gt; 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customer_confirmed: true&lt;/code&gt; is required. Without it the call is rejected with a message telling the agent to ask the shopper first.&lt;/li&gt;
&lt;li&gt;The phone must be a valid Uzbek mobile number.&lt;/li&gt;
&lt;li&gt;The same limits as our website apply: one order per phone per product per 24 hours, at most three different products.&lt;/li&gt;
&lt;li&gt;There is a per-IP hourly cap and a daily cap for the whole AI channel.&lt;/li&gt;
&lt;li&gt;Every AI order is flagged for the admins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cash on delivery helps a lot here. Even if something goes wrong, nobody gets charged. A bad order costs us one phone call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an MCP App adds
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/modelcontextprotocol/ext-apps" rel="noopener noreferrer"&gt;MCP Apps extension&lt;/a&gt; (&lt;code&gt;io.modelcontextprotocol/ui&lt;/code&gt;) 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 &lt;code&gt;postMessage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are three pieces on the server side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Tools point at a &lt;code&gt;ui://&lt;/code&gt; resource&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;APP_URI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ui://birinchi-shop/store-v4.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uiMeta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoking&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;invoked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;resourceUri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;APP_URI&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;                          &lt;span class="c1"&gt;// MCP Apps standard&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai/outputTemplate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ui://birinchi-shop/store-v4.skybridge.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ChatGPT Apps SDK&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai/widgetAccessible&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai/toolInvocation/invoking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoking&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai/toolInvocation/invoked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// in the tool list:&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search_products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;uiMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Searching the store…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Products found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four of the five tools share &lt;strong&gt;one&lt;/strong&gt; view: search, product, order and status. &lt;code&gt;store_info&lt;/code&gt; stays text-only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The resource itself&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;APP_RESOURCES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rpcError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Resource not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rpcResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// 'text/html;profile=mcp-app'&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;readAppHtml&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;resourceDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://birinchishop.uz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;connectDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;prefersBorder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CSP is tight. The view may load images and video from our own domain and nothing else, and it makes &lt;strong&gt;no network requests at all&lt;/strong&gt;. Every call to the server goes through the host as &lt;code&gt;tools/call&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The text result does not change&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The tool still returns &lt;code&gt;content&lt;/code&gt; (text) and &lt;code&gt;structuredContent&lt;/code&gt; (JSON). Clients without UI support keep working exactly as before. The view just reads &lt;code&gt;structuredContent&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One HTML file, no build step
&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;shape&lt;/em&gt; of the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;showResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isError&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;showError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;showList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// search_products&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total_to_pay&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;openProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// get_product&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next_steps&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;sc&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;showDone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// place_order&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                           &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;showStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// get_order_status&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Product names come from our database and some of them are messy, so all text goes into the DOM through &lt;code&gt;textContent&lt;/code&gt; and a tiny &lt;code&gt;h(tag, attrs, ...children)&lt;/code&gt; helper. There is no &lt;code&gt;innerHTML&lt;/code&gt; anywhere.&lt;/p&gt;

&lt;p&gt;The bridge itself is small: send &lt;code&gt;ui/initialize&lt;/code&gt;, store &lt;code&gt;hostContext&lt;/code&gt; (theme, CSS variables, locale, display mode), send &lt;code&gt;ui/notifications/initialized&lt;/code&gt;, then listen for &lt;code&gt;ui/notifications/tool-result&lt;/code&gt;. A &lt;code&gt;ResizeObserver&lt;/code&gt; reports &lt;code&gt;ui/notifications/size-changed&lt;/code&gt;, so the iframe grows when the shopper opens the order form.&lt;/p&gt;

&lt;p&gt;Tapping a card calls &lt;code&gt;get_product&lt;/code&gt; &lt;em&gt;from the view&lt;/em&gt;. The model is not involved, so browsing is instant and costs no tokens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8va0j0rw40ap5irie0ex.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8va0j0rw40ap5irie0ex.png" alt=" " width="800" height="769"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Consent belongs in the UI
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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: &lt;strong&gt;"I confirm — place order"&lt;/strong&gt;. Only that tap sends &lt;code&gt;place_order&lt;/code&gt; with &lt;code&gt;customer_confirmed: true&lt;/code&gt;. The server-side rules still apply on top.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zfzh1f3ul9e1s7caodz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zfzh1f3ul9e1s7caodz.png" alt=" " width="800" height="642"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6a4urp48i2yc38bycejb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6a4urp48i2yc38bycejb.png" alt=" " width="800" height="599"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After a successful order the view calls &lt;code&gt;ui/update-model-context&lt;/code&gt;, so the assistant knows the order already exists and does not place it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;bridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`The shopper placed order #&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; in the store app ... Do not place this order again.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;get_order_status&lt;/code&gt; renders as a simple timeline: received → operator confirms → with the courier → delivered, paid in cash.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9arvc0uwuqfyrs8jxxdf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9arvc0uwuqfyrs8jxxdf.png" alt=" " width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Four bugs that only showed up in the real host
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;clientInfo&lt;/code&gt; vs &lt;code&gt;appInfo&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first live run showed our fallback error screen. We had written the &lt;code&gt;ui/initialize&lt;/code&gt; params from memory, MCP-style, with &lt;code&gt;clientInfo&lt;/code&gt;. The view protocol uses &lt;strong&gt;&lt;code&gt;appInfo&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ui/initialize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;protocolVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-01-26&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;appInfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;birinchi-shop-app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.1.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// not clientInfo&lt;/span&gt;
  &lt;span class="na"&gt;appCapabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;availableDisplayModes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fullscreen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our harness answered any &lt;code&gt;ui/initialize&lt;/code&gt;; the real host validated the params. Lesson: copy the request shape from the SDK's types (&lt;code&gt;spec.types.ts&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A white card in a dark chat&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next, the view rendered in a dark chat with a white background and washed-out green text. The host passes theme variables as &lt;code&gt;light-dark(#fff, #262624)&lt;/code&gt;. &lt;code&gt;light-dark()&lt;/code&gt; resolves from the element's &lt;code&gt;color-scheme&lt;/code&gt;, and we never set one, so it always picked the light value. Meanwhile our own accent colors followed &lt;code&gt;data-theme="dark"&lt;/code&gt;, hence the odd mix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. ...and then a black frame around it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix for #2 was &lt;code&gt;color-scheme: dark&lt;/code&gt; on &lt;code&gt;:root&lt;/code&gt;. That made things worse in a different way. When an iframe's root &lt;code&gt;color-scheme&lt;/code&gt; 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 &lt;code&gt;color-scheme&lt;/code&gt; on the app container instead of the root, so the iframe stays transparent and the host variables resolve correctly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nf"&gt;#app&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--fg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;:root&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"dark"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nf"&gt;#app&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Hosts cache the resource&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After every HTML change, the phone kept showing the old version. Hosts cache &lt;code&gt;ui://&lt;/code&gt; resources, so we now put a version in the URI (&lt;code&gt;store-v4.html&lt;/code&gt;) and bump it with every change to the view. Reconnecting the connector refreshes the tool list.&lt;/p&gt;

&lt;p&gt;A bonus trap, specific to our setup: the Puppeteer in our frontend was so old that its bundled Chromium could not parse optional chaining (&lt;code&gt;?.&lt;/code&gt;). The harness has to use a current Chrome, or it reports syntax errors no real user will ever see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without creating orders
&lt;/h2&gt;

&lt;p&gt;Every real &lt;code&gt;place_order&lt;/code&gt; 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 &lt;code&gt;test: true&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The harness itself is under 100 lines: a page with an iframe, a fake host that answers &lt;code&gt;ui/initialize&lt;/code&gt;, forwards &lt;code&gt;tools/call&lt;/code&gt; to the real server handler, and resizes the iframe on &lt;code&gt;size-changed&lt;/code&gt;. A script then clicks through search → product → variant → form → confirm and takes screenshots in light and dark mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would tell someone starting today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep the text result first-class.&lt;/strong&gt; The UI is an extra layer; plenty of clients will never render it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the view call your tools directly&lt;/strong&gt; for browsing. It is faster and keeps the model out of simple clicks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put irreversible actions behind a button the user taps&lt;/strong&gt;, and still enforce the rules on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lock the CSP down&lt;/strong&gt; and route all data through &lt;code&gt;tools/call&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version your &lt;code&gt;ui://&lt;/code&gt; URIs.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test in a real host early&lt;/strong&gt;, on a phone, in dark mode.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you want to see it working, add our store server as a custom connector in an MCP client that supports apps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://birinchishop.uz/api/shop/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API key is needed. Ask for something like "find a watch in Birinchi Shop". The usage guide is at &lt;a href="https://birinchishop.uz/en/ai-shopping?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mcp_app" rel="noopener noreferrer"&gt;birinchishop.uz/en/ai-shopping&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Questions about the implementation are welcome in the comments.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
