Interactive UI (MCP-UI)
Most MCP tools answer with text. Some workflows are far nicer as a form or dialog — pick options from a dropdown, fill a few fields, click a button. MCP-UI lets a trusted MCP server return its own interactive UI, which WizChat renders right inside the chat (in a locked-down frame). When the user clicks a button, it runs a follow-up tool on that same server and the result updates the dialog in place — no page reload, no leaving the conversation.
This is built on the open MCP Apps standard (the ui:// resource extension).
Chatbot owners decide whether to turn this on for a server (one toggle, below). Developers building the MCP server author the actual dialog. If your server only returns text/data, you don't need any of this.
Turning it on
By default, WizChat ignores any UI a server tries to render — a server can only draw in your chat if you explicitly allow it.
- Open Chatbot → MCP Servers, then Add or Edit the server.
- Enable "Allow this server to render UI in chat (MCP-UI)".
- Save.
A server with this on can draw arbitrary interface inside your chatbot's conversation. Leave it off for any third-party or unvetted server. WizChat sandboxes the UI heavily (see below), but the toggle is your first line of defense — treat it like granting a capability.
How it works
sequenceDiagram
participant U as User
participant W as WizChat
participant M as MCP Server
U->>W: Opens the workflow
W->>M: tool call
M-->>W: returns a UI resource (ui://…)
Note over W: Renders it in a sandboxed frame in the chat
W-->>U: Interactive dialog appears
U->>W: Clicks a button in the dialog
Note over W: Re-checks login + permissions server-side
W->>M: runs the bound follow-up tool
M-->>W: result
W-->>U: Dialog updates in place
The dialog is the server's own HTML, but it never runs with your chatbot's privileges — it lives in an isolated sandbox and can only ask WizChat to run that server's tools.
Security model
This feature is fail-closed and gated at several layers:
- Login required. It only works on a chatbot that requires login; every action runs as the signed-in user.
- Sandboxed frame. The dialog runs with scripts only — no access to your chatbot's page, cookies, or storage, and a default-deny content policy (it can't call external services unless you allow-list them).
- Actions are re-checked server-side. Every button click is re-verified by WizChat — your identity, your access to that server, and that the requested tool is one this dialog is actually allowed to run. The dialog can never reach a different server or a tool it wasn't given.
- No secrets in the browser. The server's access key never leaves WizChat's backend.
For developers: authoring the dialog
A tool returns a UI resource in its result instead of plain text:
uri:ui://<your-feature>/<id>- content type
rawHtml— a self-contained HTML document (your own markup, styles, and scripts) with thetext/html;profile=mcp-appmimeType. - Buttons drive the workflow by asking the host to run a follow-up tool, and the result is delivered back to your dialog (shown below).
- Keep the dialog self-contained: because of the default-deny content policy, it can't fetch external scripts, fonts, or APIs unless those domains are allow-listed. Inline what you need (including the SDK script — see the gotcha).
WizChat renders these with the MCP-UI client's AppRenderer — the host side of the open MCP Apps standard (SEP-1865). Communication with your dialog is MCP-over-postMessage (the "AppBridge"), not a bare window.parent.postMessage.
This is the #1 gotcha. The host (AppRenderer) speaks the MCP Apps AppBridge protocol. A dialog that hand-rolls window.parent.postMessage({type:'tool'}) / ui-lifecycle-iframe-ready with no bridge will render but never connect — buttons do nothing, no initial data arrives, any wizchat/llm call falls back to an offline stub. There are exactly two supported ways to make a dialog speak AppBridge:
- TypeScript servers —
@mcp-ui/server'screateUIResourceinjects the MCP Apps adapter into your HTML. With the adapter present, theui-lifecycle-iframe-ready+{type:'tool'}postMessage code in the example below just works — the adapter translates it to AppBridge for you. Build the resource withcreateUIResource(don't hand-write the raw HTML and return it directly, or the adapter is missing). - Any language (Python, Go, …) — the native
@modelcontextprotocol/ext-appsAppSDK, inlined in your HTML. You callapp.connect(),app.callServerTool(...), and listen onapp.ontoolinput/ontoolresult. See Non-TypeScript servers below. This is the right path for FastMCP and other non-TS servers, which have no@mcp-ui/server.
Use @modelcontextprotocol/ext-apps@^1.7.x — keep it compatible with @mcp-ui/client v7 (WizChat's host currently consumes @modelcontextprotocol/ext-apps@^1.7.4).
A complete minimal dialog (TypeScript / @mcp-ui/server adapter path)
1. The server tool returns the dialog as a UI resource. Build it with @mcp-ui/server's createUIResource — that's what injects the MCP Apps adapter that makes the postMessage code in step 2 connect. (The resource shape below is the same in any language — it's what a Python EmbeddedResource serializes to — but a hand-built raw resource carries no adapter; non-TS servers use the App SDK path instead.)
// tool result
return {
content: [
{
type: 'resource',
resource: {
uri: 'ui://my-feature/dialog',
mimeType: 'text/html;profile=mcp-app',
text: DIALOG_HTML, // the self-contained HTML document below
},
},
],
};
2. The dialog HTML — a self-contained document. The postMessage code below is what the createUIResource adapter bridges to AppBridge; it does not work on its own without that adapter (or the App SDK path):
<!doctype html>
<meta charset="utf-8" />
<body>
<button id="run">Run</button>
<pre id="out"></pre>
<script>
// ── MCP-UI bridge (translated to AppBridge by the createUIResource adapter) ──
// Every call posts {type:'tool', messageId, payload:{toolName, arguments}};
// the adapter replies {type:'ui-message-response', messageId, payload}.
// Wire format per @mcp-ui/client v7 — re-verify these field names on SDK bumps.
const pending = new Map();
let seq = 0;
function callTool(toolName, args) {
const messageId = 'm' + ++seq;
return new Promise((resolve, reject) => {
pending.set(messageId, { resolve, reject });
// Production: also start a timeout that calls reject() + pending.delete(messageId),
// so a dropped/never-arriving host reply fails the call instead of hanging silently.
window.parent.postMessage(
{ type: 'tool', messageId, payload: { toolName, arguments: args || {} } },
'*'
);
});
}
window.addEventListener('message', (e) => {
const msg = e.data;
if (!msg || typeof msg !== 'object') return;
// Initial data the host hands you at open time (e.g. the user's current record).
if (msg.type === 'ui-lifecycle-iframe-render-data') {
const data = msg.payload && msg.payload.renderData;
// …populate your form from `data`…
return;
}
if (msg.type === 'ui-message-response') {
const p = pending.get(msg.messageId);
if (!p) return;
pending.delete(msg.messageId);
const payload = msg.payload || {};
if (payload.error) return p.reject(new Error(String(payload.error)));
// Resolve the full CallToolResult (a `{ content, structuredContent? }`
// object). The `?? payload` keeps this working whether the host nests
// the result under `payload.response` or returns it directly.
p.resolve(payload.response ?? payload);
}
});
// Required: announce readiness (lets the host send render-data).
window.parent.postMessage({ type: 'ui-lifecycle-iframe-ready' }, '*');
// ── Example: run one of this server's own tools and show the result ──
document.getElementById('run').addEventListener('click', async () => {
const result = await callTool('my_tool', { /* your tool's arguments */ });
document.getElementById('out').textContent = JSON.stringify(result, null, 2);
});
</script>
</body>
result here is the tool's CallToolResult ({ content: [...] , structuredContent? }) — read result.content / result.structuredContent depending on what your tool returns.
Non-TypeScript servers: the MCP Apps App SDK
If your server isn't TypeScript (e.g. Python / FastMCP, Go, …), you can't use @mcp-ui/server's createUIResource, so there's no adapter — the postMessage code above won't connect. Instead, speak AppBridge directly with the native @modelcontextprotocol/ext-apps App SDK (^1.7.x, matching the host). Because of the default-deny content policy, bundle the SDK into your HTML at build time — a runtime import (remote or relative) is blocked from a ui:// page, which has no same-origin to fetch from, unless you allow-list a domain in the resource's CSP.
<!doctype html>
<meta charset="utf-8" />
<body>
<button id="run">Run</button>
<pre id="out"></pre>
<script type="module">
// BUILD STEP: bundle @modelcontextprotocol/ext-apps + this code into ONE inline
// module (e.g. `esbuild --bundle`), so there's NO runtime import — the default-deny
// CSP blocks remote/relative module loads from a ui:// page. After bundling, `App`
// is in scope here (it came from the bundled ext-apps source).
const app = new App({ name: 'my-feature', version: '1.0.0' }, {}, { autoResize: true });
let connected = false;
// Register handlers BEFORE connect() — these one-shot events can be missed otherwise.
// Initial data the host hands you at open time (the args the UI tool was opened with,
// and its structured result).
app.addEventListener('toolinput', (p) => { /* p.arguments — populate your form */ });
app.addEventListener('toolresult', (p) => { /* p.structuredContent — seed from the result */ });
// Call one of THIS server's tools (proxied through the host, re-gated server-side).
async function callTool(name, args) {
const res = await app.callServerTool({ name, arguments: args || {} }); // CallToolResult
if (res?.isError) throw new Error(res.content?.[0]?.text || (name + ' failed'));
return res;
}
document.getElementById('run').addEventListener('click', async () => {
if (!connected) return;
const result = await callTool('my_tool', { /* args */ });
document.getElementById('out').textContent = JSON.stringify(result, null, 2);
});
try { await app.connect(); connected = true; } catch (e) { /* opened with no host → degrade */ }
</script>
</body>
Same capabilities as the adapter path — app.callServerTool is the equivalent of callTool, and the toolinput/toolresult events replace ui-lifecycle-iframe-render-data. Use app.callServerTool({ name: 'wizchat/llm', … }) for in-dialog AI completions (below). Do not use app.createSamplingMessage for that — WizChat brokers completions through the reserved tool name, not MCP sampling.
AI completions inside a dialog
A dialog can ask WizChat to run an LLM completion on its behalf — without going back through the chat agent. This is useful for in-dialog assistants: "rewrite this rule in plain English", "explain what these settings do", "turn this sentence into a config object". The completion runs on your chatbot's configured model (including any per-server model override) and the cost is tracked like any other WizChat usage. Your server needs no API key of its own — WizChat brokers the call, and completions are billed to the chatbot owner's WizChat usage (not the dialog user's).
wizchat/llm is the dialog calling a model with a prompt. If instead you want a named tool whose result is produced by a model you pick — including image generation — declare it in the tool's metadata. See Model-backed tools.
Turning it on
This is a second, separate capability on top of MCP-UI:
- Open Chatbot → MCP Servers, then Edit the server.
- Make sure "Allow this server to render UI in chat (MCP-UI)" is on.
- Enable "Allow this server's dialogs to run LLM completions".
- Save.
The LLM toggle only takes effect when MCP-UI is also on for that server — a dialog has to exist before it can ask for a completion. Turning UI off automatically disables completions too.
Using it from the dialog
Inside your dialog, call the reserved tool name wizchat/llm through the same channel you use for any tool — your adapter callTool(...) (TS path) or app.callServerTool({ name: 'wizchat/llm', ... }) (App SDK path). WizChat intercepts that reserved name and runs the completion; it is not MCP sampling:
const result = await callTool('wizchat/llm', {
prompt: 'Rewrite the following in plain English:\n' + someText,
systemPrompt: 'You are a concise assistant.', // optional
maxTokens: 400, // optional
});
// `result` is a CallToolResult, NOT a bare string — pull the text out of content[0].text:
const text = (result?.content || []).find((c) => c.type === 'text')?.text ?? '';
prompt(required) — the user/content prompt.systemPrompt(optional) — a system instruction.maxTokens(optional) — clamped server-side to a safe ceiling (currently 2,000 tokens).- The combined
prompt+systemPromptis capped at ~100 KB — oversize requests are rejected.
The call returns a CallToolResult ({ content: [{ type: 'text', text }] }) — read the completion from content[0].text, as above. There is no streaming — you get the full answer when it's done. The call has a 30-second timeout: if the gateway hangs you'll get a failure result, not a partial one. Keep all prompt-building in the dialog, and validate before you apply: if the model returns a config object, run it through your own validation tool before committing it.
Treat completions as suggestions. Always show the result to the user and validate any structured output (e.g. with your server's existing validation tool) before saving or acting on it.
Security & limits
- Off by default. A dialog can only run completions when the owner has enabled
allowLlmCompletionfor that server. Otherwise the call is rejected. - Re-gated per call. Like every dialog action, each
wizchat/llmcall re-verifies the signed-in user, their access to that server, and the live owner toggle — server-side. - Bounded. Each opened dialog can run up to 5 completions, and there's a per-user rate limit on top of that. Reopen the dialog if you hit the cap.
- No prompt/output retention in telemetry. WizChat records the call for cost/observability but does not retain the prompt or completion text in its tracing.
- Gateway model only. Completions run on the owner's configured/override model via the AI Gateway — a dialog can't pick its own model or pass its own credentials.
Opening a dialog directly (deep-link)
Normally a dialog appears when a tool is called in the chat. A host that embeds the chatbot — e.g. a desktop add-in pointing a WebView at the chatbot URL — can instead open a dialog directly, with no chat turn and no agent, by loading:
https://<your-chatbot-domain>/?app=<toolName>
WizChat runs the auth gates, calls that UI tool, and renders its dialog full-screen. It's generic — any server with a UI-returning tool works via ?app=<its tool>; nothing is product-specific.
- Tool arguments ride as extra query params, e.g.
…/?app=my_tool&someId=42→ passed to the tool as{ someId: '42' }(these become the dialog's initialtoolinput). All query values arrive as strings — coerce server-side if your tool schema expects numbers/booleans. &scope=<serverId>[,<serverId>]constrains the open to those scopes (the same per-scope allow-list the in-chat path applies).- Host-supplied arguments (data the URL can't carry). A query string can't hold large or structured data. An embedding host can instead set a global
window.__wizchatAppArgs(a plain object) — it's merged into the open arguments (host values win over query params), so data the host can only obtain natively (e.g. a desktop add-in reading the current file/document) reaches the tool and the dialog's initialtoolinput. Set it before the page loads so it's present when the app mounts — in a WebView, inject it at document-creation (e.g.AddScriptToExecuteOnDocumentCreatedAsync), the same place you wrapfetchfor the access key. It's still bounded by the open route's argument size cap; unset (a plain browser) → no effect. Use the same argument keys your dialog expects in itstoolinputhandler.
// host side (e.g. the add-in WebView), injected before the chatbot page runs:
window.__wizchatAppArgs = { my_data: nativelyReadValue };
// → reaches open_<tool>'s arguments → the guest's toolinput event
The dialog's client calls (/api/mcp-ui-open, /api/mcp-ui-action, /api/mcp-llm) don't carry the server's access key — by design, the key never sits in page JS. If your server is key-gated, the host must attach the X-Wizchat-Access-Key header (the same key it already uses for chat). In a WebView, wrap fetch at document-creation to add the header on those routes. A plain browser link to a key-gated ?app= open will get a 403.
The deep-linked dialog is the same MCP-Apps dialog as in-chat — it must be MCP-Apps-compliant (adapter or App SDK) exactly as above.
Requirements & limits
- The chatbot must require login (this won't activate on an open/anonymous chatbot).
- The server keeps its normal access controls (allow-list and/or access key); MCP-UI is an addition, not a replacement.
- One dialog can drive a bounded number of actions before it must be reopened.
FAQ
Is this standard MCP, or something WizChat-specific?
The UI itself is standard — it's the open MCP Apps extension (SEP-1865): ui:// resources, rawHtml, and the AppBridge (MCP-over-postMessage) protocol, reached via @mcp-ui/server's adapter or the @modelcontextprotocol/ext-apps App SDK. Any MCP-Apps-compliant server renders in WizChat with no WizChat-specific code. The one extension is the reserved tool name wizchat/llm for in-dialog completions — and that's opt-in and feature-detectable (see below).
My dialog renders but stays "offline" — buttons do nothing, no data loads. Why?
It's not MCP-Apps-compliant, so it never connects to the host's AppBridge. The HTML loads fine, but with no adapter (and no App SDK) its window.parent.postMessage calls go nowhere: the host doesn't answer button clicks, no initial toolinput/render-data arrives, and wizchat/llm returns an offline stub. Fix it by building the resource with @mcp-ui/server's createUIResource (TS — injects the adapter) or by using the @modelcontextprotocol/ext-apps App SDK (any language; see above). Returning hand-written raw HTML with bare postMessage and neither of those is the most common cause.
Do I have to add WizChat-specific code to my MCP server?
No WizChat-specific code — but your dialog does have to be MCP-Apps-compliant (built via @mcp-ui/server's createUIResource, or using the @modelcontextprotocol/ext-apps App SDK). That's standard MCP Apps, not WizChat-specific. You only touch wizchat/llm if you want in-dialog AI completions — and even then it's a single reserved tool name on that same standard channel.
Does using wizchat/llm lock my server to WizChat?
No. It's a plain tool name on the standard MCP-Apps channel. On a host that doesn't support it, the call rejects with an unknown-tool error — so you can feature-detect: try it, and fall back (hide the AI button, or call your own backend) if it errors. Your server stays a standard MCP server everywhere else.
Do I need the @mcp-ui/server SDK?
It's one of the two supported paths, not a hard requirement — but you do need one of them. @mcp-ui/server's createUIResource is the easiest for TypeScript servers because it injects the MCP-Apps adapter for you. Any other language uses the @modelcontextprotocol/ext-apps App SDK instead. What you can't do is hand-build a raw ui:// resource with bare postMessage and neither of those — the right shape alone isn't enough; with no adapter and no App SDK it renders but stays offline. If your server only returns text/data, you need none of this.
Why a reserved tool name instead of MCP "sampling"?
MCP sampling is the spec's native way for a server to ask the host's LLM for a completion. WizChat's MCP transport layer can't yet advertise the sampling capability to servers, so the reserved-tool approach is the supported path today. Native sampling is tracked as a future enhancement; the dialog-facing contract (wizchat/llm) is designed to stay stable regardless.
Can the wizchat/llm AI call my other tools to gather the data it needs?
No. wizchat/llm is a single completion, not an agent — it has no tool-calling ability. It receives only the prompt (plus optional systemPrompt) you send and returns text. It cannot call any of your MCP tools on its own — even if your prompt instructs it to. There is no tool loop on this path; that's deliberate, because the feature runs the model without the chat agent.
So if the completion needs data that lives behind your other tools (a schema, a list of valid options, an example record), your dialog must fetch it first and include it in the prompt. The dialog is the orchestrator: call your own tools through the normal callTool channel, then embed their results in the wizchat/llm prompt.
// 1. Gather whatever context the model needs, using your own tools FIRST.
const context = await callTool('your_data_tool', { /* args */ });
// 2. Hand that data to the LLM in the prompt — it cannot fetch it itself.
const result = await callTool('wizchat/llm', {
prompt: 'Relevant data:\n' + JSON.stringify(context) +
'\n\n' + userInstruction,
});
If you need a model that autonomously decides which tools to call and chains them, that is the main chat agent — not an in-dialog completion. The in-dialog wizchat/llm is intentionally a one-shot "transform this text" call.
Can a dialog read or write files on the user's machine?
Not by itself — the dialog runs in a locked-down sandbox with no filesystem access. If your product needs to persist to a local file (e.g. a desktop add-in that embeds the chat), the host application does the saving: your dialog returns the data, and your host's code writes it. WizChat never touches the local disk.
How do I embed this in my own software (not just the chat widget)?
A few ways: render the WizChat chat widget inside your app and let dialogs appear there; open a dialog directly by pointing a WebView at …/?app=<toolName> (no chat turn); or host the chat in a WebView/embed and handle host-side actions (like local file saves) yourself. Either way the MCP-Apps and wizchat/llm contracts are the same — your server doesn't change. (Key-gated server in a WebView? The host injects X-Wizchat-Access-Key — see the deep-link note above.)