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.
!Image description
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'), ... }
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.
!Image description
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.
!Image description
!Image description
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.
!Image description
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.
Questions about the implementation are welcome in the comments.
Your MCP App and the Assistant Need the Same Selection
Imagine comparing two events inside an AI conversation. You change the time filter, remove one event, and ask, “Which of these leaves enough time for dinner?” The panel shows your new shortlist. The assistant answers about the old one. Every individual control may be working; the two interfaces are working from different state.
Disclosure: An AI agent researched and drafted this article from my public project documentation and the linked MCP sources. The example and state-synchronization design below are illustrative recommendations, not a report of a defect or a claim about undocumented implementation details.
My AfroTech Guide case study describes a community-built public guide and a connected AI experience for discovery, comparisons, planning, and editable introduction drafts. Its MCP App UI lets supported hosts display structured records and plans. Public website features and connected AI features have different release scopes, and real-device and host acceptance remain separate checks.
That combination makes an easily missed product question concrete: after someone changes an interactive panel, what does the assistant know when the conversation continues?
An interactive result creates another source of context
The MCP Apps specification, marked stable on January 26, 2026, defines an optional extension for interactive interfaces delivered through MCP. This is an ongoing shift toward tools people can manipulate inside a conversation, rather than a newly announced release this week.
A user can now express intent in two places: a message and a control. Selecting a record, changing a filter, or clearing a shortlist can change the meaning of the next sentence without adding a new chat message.
For an event planner, “these” might mean the selected cards, the visible search results, or the events already saved to a plan. Those are different sets. A useful interface should make the active set visible and carry that meaning into the context supplied to the assistant.
I would start with an explicit definition for each view. A comparison panel could treat “these events” as the selected record IDs. A search panel could describe its visible results and filters while saying that nothing is selected. If the product cannot resolve the reference reliably, asking which set the user means is better than guessing.
Publish a coherent snapshot after a committed change
The SDK's updateModelContext documentation describes making app state available for future model turns without immediately triggering a response. It also says that typically only the latest update is sent, with each update replacing the previous context.
That behavior has a practical consequence: a sequence of tiny updates should not be treated as an event log the model is guaranteed to replay. Sending “removed event A” followed by “changed time filter” may leave the later context without enough information to reconstruct the shortlist.
For this hypothetical planner, I would publish a compact snapshot containing the view name, current filters, selected IDs, brief labels for those IDs, and a local state revision. Include the selected count as a convenience, while keeping the IDs available to resolve the records. Clearing the selection should publish an explicit empty selection.
Send the snapshot after the application has accepted the change into its own state. A pending search should be described as pending. Otherwise the assistant may receive new filters paired with results from the previous request.
The snapshot need not contain every record on the screen. It should carry enough meaning for the next question and identifiers for fetching details when needed. Smaller context can reduce unnecessary payload, but token and latency savings should be measured in the actual host workflow.
Make late results and failed updates visible
Suppose the user quickly chooses a morning filter and then an afternoon filter. The morning request finishes last. If the application accepts every response in arrival order, both the panel and the assistant's context can regress to a view the user already replaced.
I would associate each request with the state revision that created it and reject a result that no longer belongs to the active view. This is a proposed application design, not an automatic guarantee supplied by MCP.
Context delivery also needs its own failure handling. The SDK documents acknowledgment, rejection, timeout, and connection-loss outcomes. An acknowledged update is still different from observing a correct answer on the next turn. Test both the delivery path and the resulting conversational behavior.
If delivery fails, keep the local selection intact and make the limitation visible. An application can offer a retry or a concise way to restate the selection. Silently pretending that the assistant received the change leaves the user with no way to understand an outdated answer.
Keep selecting, discussing, and saving separate
A person choosing three cards has expressed an interest in discussing them. That interaction alone should not be described as a saved itinerary, an RSVP, or permission to contact someone.
The distinction fits the documented guide: record identity carries into plans, and a save is only described as complete after backend confirmation. The product also keeps editable introduction drafts separate from sending messages. A context snapshot should preserve these differences, for example by identifying a shortlist as unsaved.
I will discuss the build behind this independent guide in Building AfroTech.guide, listed on my official AFROTECH speaker profile. The project is community-built; that speaking engagement does not make the guide an official conference service.
For testing, exercise the second turn after changing a filter, clearing a selection, receiving a late result, and reconnecting. Check what the assistant refers to, and whether its account of saved state agrees with the backend. Passing a render test alone cannot establish that the conversational handoff works.
This matters wherever interactive AI panels support comparisons: directories, catalogs, dashboards, and planning tools. Each visible change can alter the user's next question. Treating that handoff as part of the interface gives people a conversation that follows their decisions instead of making them narrate every click.
1 reaction
Mentions
Try it in @mcpjams! Let us know what other extensions/roadmap items you're excited about! - Try it: mcpjam.com - Draft proposal PR: ext-apps#798 - Try WebMCP + MCP Apps: mcpjam.com - Test WebMCP in MCPJam: youtube.com - Test Skills Over MCP in MCPJam: youtube.com
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. !Image description 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'), ... } 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. !Image description 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. !Image description !Image description 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. !Image description 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. Questions about the implementation are welcome in the comments.
@ndmccormack @OpenAI So, I guess this is the draft? ext-apps#798
Also point your agent here to learn more modelcontextprotocol/ext-apps
Your MCP App and the Assistant Need the Same Selection Imagine comparing two events inside an AI conversation. You change the time filter, remove one event, and ask, “Which of these leaves enough time for dinner?” The panel shows your new shortlist. The assistant answers about the old one. Every individual control may be working; the two interfaces are working from different state. Disclosure: An AI agent researched and drafted this article from my public project documentation and the linked MCP sources. The example and state-synchronization design below are illustrative recommendations, not a report of a defect or a claim about undocumented implementation details. My AfroTech Guide case study describes a community-built public guide and a connected AI experience for discovery, comparisons, planning, and editable introduction drafts. Its MCP App UI lets supported hosts display structured records and plans. Public website features and connected AI features have different release scopes, and real-device and host acceptance remain separate checks. That combination makes an easily missed product question concrete: after someone changes an interactive panel, what does the assistant know when the conversation continues? An interactive result creates another source of context The MCP Apps specification, marked stable on January 26, 2026, defines an optional extension for interactive interfaces delivered through MCP. This is an ongoing shift toward tools people can manipulate inside a conversation, rather than a newly announced release this week. A user can now express intent in two places: a message and a control. Selecting a record, changing a filter, or clearing a shortlist can change the meaning of the next sentence without adding a new chat message. For an event planner, “these” might mean the selected cards, the visible search results, or the events already saved to a plan. Those are different sets. A useful interface should make the active set visible and carry that meaning into the context supplied to the assistant. I would start with an explicit definition for each view. A comparison panel could treat “these events” as the selected record IDs. A search panel could describe its visible results and filters while saying that nothing is selected. If the product cannot resolve the reference reliably, asking which set the user means is better than guessing. Publish a coherent snapshot after a committed change The SDK's updateModelContext documentation describes making app state available for future model turns without immediately triggering a response. It also says that typically only the latest update is sent, with each update replacing the previous context. That behavior has a practical consequence: a sequence of tiny updates should not be treated as an event log the model is guaranteed to replay. Sending “removed event A” followed by “changed time filter” may leave the later context without enough information to reconstruct the shortlist. For this hypothetical planner, I would publish a compact snapshot containing the view name, current filters, selected IDs, brief labels for those IDs, and a local state revision. Include the selected count as a convenience, while keeping the IDs available to resolve the records. Clearing the selection should publish an explicit empty selection. Send the snapshot after the application has accepted the change into its own state. A pending search should be described as pending. Otherwise the assistant may receive new filters paired with results from the previous request. The snapshot need not contain every record on the screen. It should carry enough meaning for the next question and identifiers for fetching details when needed. Smaller context can reduce unnecessary payload, but token and latency savings should be measured in the actual host workflow. Make late results and failed updates visible Suppose the user quickly chooses a morning filter and then an afternoon filter. The morning request finishes last. If the application accepts every response in arrival order, both the panel and the assistant's context can regress to a view the user already replaced. I would associate each request with the state revision that created it and reject a result that no longer belongs to the active view. This is a proposed application design, not an automatic guarantee supplied by MCP. Context delivery also needs its own failure handling. The SDK documents acknowledgment, rejection, timeout, and connection-loss outcomes. An acknowledged update is still different from observing a correct answer on the next turn. Test both the delivery path and the resulting conversational behavior. If delivery fails, keep the local selection intact and make the limitation visible. An application can offer a retry or a concise way to restate the selection. Silently pretending that the assistant received the change leaves the user with no way to understand an outdated answer. Keep selecting, discussing, and saving separate A person choosing three cards has expressed an interest in discussing them. That interaction alone should not be described as a saved itinerary, an RSVP, or permission to contact someone. The distinction fits the documented guide: record identity carries into plans, and a save is only described as complete after backend confirmation. The product also keeps editable introduction drafts separate from sending messages. A context snapshot should preserve these differences, for example by identifying a shortlist as unsaved. I will discuss the build behind this independent guide in Building AfroTech.guide, listed on my official AFROTECH speaker profile. The project is community-built; that speaking engagement does not make the guide an official conference service. For testing, exercise the second turn after changing a filter, clearing a selection, receiving a late result, and reconnecting. Check what the assistant refers to, and whether its account of saved state agrees with the backend. Passing a render test alone cannot establish that the conversational handoff works. This matters wherever interactive AI panels support comparisons: directories, catalogs, dashboards, and planning tools. Each visible change can alter the user's next question. Treating that handoff as part of the interface gives people a conversation that follows their decisions instead of making them narrate every click.
>"Any details about the streaming ui lib they mention or about the format of the ui code?" It's SEP-1865: modelcontextprotocol.io and modelcontextprotocol/ext-apps