MCP Apps (Widget UI)
Phía web của AI App là các UI Widget — component React được nhúng vào host dưới dạng iframe. Framework cung cấp @v-miniapp/ai/web với đầy đủ components và hooks để widget có thể giao tiếp với MCP Server và host.
Cấu trúc Widget
Thư mục
Mỗi widget là một thư mục trong web/src/widgets/, bắt buộc có index.tsx (entry point):
web/src/
├── widgets/
│ ├── manage-todos/ ← tên widget = tên resource
│ │ ├── index.tsx ← React component + renderWidget()
│ │ ├── index.css ← Styles (tuỳ chọn)
│ │ └── components/ ← Components riêng của widget (tuỳ chọn)
│ │ └── todo-item.tsx
│ └── order-list/
│ └── index.tsx
├── components/ ← Components dùng chung giữa các widget
│ ├── empty-state.tsx
│ └── loading-skeleton.tsx
├── hooks/ ← Custom hooks dùng chung
│ └── use-debounce.ts
├── styles/ ← CSS dùng chung (tuỳ chọn)
│ └── shared.css
├── locales/ ← i18n (tuỳ chọn)
│ ├── en.json
│ ├── vi.json
│ └── index.ts
├── global.d.ts ← Type augmentation cho hooks
└── vite.config.ts
Bên ngoài widgets/, bạn có thể tạo components/, hooks/, utils/, styles/,... để chia sẻ code giữa các widget — giống cách tổ chức một React app thông thường. Chỉ có thư mục widgets/<name>/ là có quy tắc đặt tên riêng.
Widget được render trong iframe bên trong giao diện chat — mỗi lần AI gọi tool là một lần widget được load lại. Hãy giữ bundle nhỏ để widget hiển thị nhanh:
- Ưu tiên các thư viện nhẹ hoặc dùng API có sẵn của browser
- Hạn chế import các library nặng (charting, rich-text editor,...)
- Tận dụng
@v-miniapp/ui-reactđã có sẵn trong framework — không cần thêm UI library khác - Lấy data qua tool — dùng
useCallTool/callToolthay vì gọifetchtrực tiếp từ widget. Mọi logic gọi API nên đặt trong handler của tool trên server, widget chỉ gọi tool và nhận kết quả
Quy tắc đặt tên
Tên thư mục widget phải trùng với tên resource đã đăng ký trên server qua registerResource:
server
.registerResource('manage-todos') // ← tên resource
.registerTool({
name: 'list-todos',
resourceName: 'manage-todos', // ← tool gắn với resource này
// ...
})
web/src/widgets/manage-todos/ ← thư mục widget khớp tên resource
Nếu tên thư mục widget không khớp với tên resource, host sẽ không tìm thấy widget để hiển thị khi tool được gọi.
Entry point — renderWidget
Mỗi widget phải gọi renderWidget() để mount React component vào iframe:
import { renderWidget } from '@v-miniapp/ai/web'
function TodoApp() {
// ... component logic
return <main>...</main>
}
renderWidget(<TodoApp />)
renderWidget sẽ:
- Mount component vào DOM (
#root) - Khởi tạo kết nối với MCP Server và host
- Cung cấp context cho các hooks (
useToolInfo,useCallTool,useHostContext,...)
Luồng kết nối Tool → Resource → Widget
1. Server đăng ký resource → registerResource('manage-todos')
2. Server đăng ký tool → registerTool({ name: 'list-todos', resourceName: 'manage-todos' })
3. AI gọi tool → list-todos
4. Server trả kết quả → { content, structuredContent: { todos } }
5. Host mở widget → web/src/widgets/manage-todos/index.tsx
6. Widget nhận data → useToolInfo<'list-todos'>() → getStructuredResult(toolInfo)
useToolInfo vs useCallTool
Hai hook chính để widget làm việc với tool, nhưng phục vụ hai chiều ngược nhau:
useToolInfo | useCallTool | |
|---|---|---|
| Ai kích hoạt | AI Agent | Người dùng (qua widget) |
| Mục đích | Nhận data ban đầu khi widget mở | Gọi tool khi người dùng tương tác |
Có callTool() | ❌ | ✅ |
| Lấy kết quả qua | .result | .data hoặc return của callTool() |
Flow: useToolInfo — widget nhận data từ AI
Dùng khi widget bị động — mở ra vì AI vừa gọi tool và cần hiển thị kết quả ngay lập tức.
Người dùng: "Cho tôi xem đơn hàng"
│
▼
AI Agent gọi tool list-orders
│
▼
Server xử lý → trả về { content, structuredContent: { orders } }
│
▼
Host mở Widget
│
▼
useToolInfo<'list-orders'>()
└── getStructuredResult(toolInfo)?.orders → render danh sách
Flow: useCallTool — widget chủ động gọi tool
Dùng khi người dùng tương tác bên trong widget và cần gọi thêm tool mà không cần AI can thiệp.
Widget đang hiển thị danh sách đơn hàng
│
Người dùng nhấn [Huỷ đơn #123]
│
▼
useCallTool('cancel-order').callTool({ id: '123' })
│
▼
Server xử lý → trả về danh sách đã cập nhật
│
▼
setOrders(getStructuredResult(result)?.orders) → re-render
Trong thực tế, widget thường dùng cả hai cùng lúc: useToolInfo để lấy data ban đầu, useCallTool để xử lý các thao tác tiếp theo của người dùng. Xem ví dụ cụ thể tại Todo AI App.
Gọi Tool từ Widget
Framework cung cấp hai cách gọi tool từ phía widget — chọn theo ngữ cảnh sử dụng:
useCallTool | callTool | |
|---|---|---|
| Loại | React hook | Hàm thông thường |
| Dùng trong | React component | Ngoài component (utility, helper) |
| Quản lý state | ✅ Tự động (isPending, data, error) | ❌ Phải tự quản lý |
| Re-render | ✅ Component tự re-render khi có kết quả | ❌ Không trigger re-render |
Dùng useCallTool khi kết quả cần cập nhật UI — binding với React state flow.
Dùng callTool khi gọi tool trong logic không cần render: middleware, transform, fire-and-forget.
useCallTool (hook)
Hook dùng để gọi tool từ MCP Server bên trong React component. Trả về trạng thái của lần gọi gần nhất.
import { useCallTool } from '@v-miniapp/ai/web'
function SearchForm() {
const { callTool, isPending, isSuccess, isError, data, error } =
useCallTool('search-products')
const handleSearch = async () => {
await callTool({ keyword: 'áo thun', category: 'thời trang' })
}
return (
<div>
<button onClick={handleSearch} disabled={isPending}>
{isPending ? 'Đang tìm...' : 'Tìm kiếm'}
</button>
{isSuccess && <ProductGrid items={data} />}
{isError && <p>Lỗi: {error.message}</p>}
</div>
)
}
Trạng thái (status)
| Status | Mô tả |
|---|---|
idle | Chưa gọi tool lần nào |
pending | Đang chờ kết quả |
success | Gọi thành công, data có giá trị |
error | Gọi thất bại, error có thông tin lỗi |
callTool (non-hook)
Dùng khi cần gọi tool ngoài React component (ví dụ: trong event handler, utility function):
import { callTool } from '@v-miniapp/ai/web'
async function searchProducts(keyword: string) {
const result = await callTool('search-products', { keyword })
return result
}
Quan sát Tool được Host Gọi
useToolInfo dùng khi widget cần hiển thị/xử lý kết quả từ tool do host trigger (không phải widget tự gọi).
getStructuredResult
getStructuredResult trích xuất structuredContent từ tool state hoặc raw result. Nó kiểm tra trạng thái (isSuccess) trước khi truy cập data — dev không cần tự check:
import { useToolInfo, getStructuredResult } from '@v-miniapp/ai/web'
function OrderList() {
const toolInfo = useToolInfo<'list-orders'>()
const data = getStructuredResult(toolInfo)
// data: { orders: Order[] } | undefined — chỉ có giá trị khi isSuccess
if (toolInfo.isPending) return <Skeleton />
if (!data) return null
return <Orders items={data.orders} />
}
getStructuredResult với callTool
getStructuredResult cũng hoạt động với return value từ callTool():
import { useCallTool, getStructuredResult } from '@v-miniapp/ai/web'
function TodoWidget() {
const [todos, setTodos] = useState<Todo[]>([])
const addTool = useCallTool('add-todo')
const toggleTool = useCallTool('toggle-todo')
const deleteTool = useCallTool('delete-todo')
const handleAdd = async () => {
const result = await addTool.callTool({ text: newText.trim() })
setTodos(getStructuredResult(result)?.todos ?? [])
}
const handleToggle = async (id: string) => {
const result = await toggleTool.callTool({ id })
setTodos(getStructuredResult(result)?.todos ?? [])
}
const handleDelete = async (id: string) => {
const result = await deleteTool.callTool({ id })
setTodos(getStructuredResult(result)?.todos ?? [])
}
// ...
}
getStructuredResult nhận 3 dạng input| Input | Từ đâu |
|---|---|
IToolState | useToolInfo() — widget nhận data từ AI |
ICallToolState | useCallTool() — state hiện tại của hook |
CallToolResult | callTool() / useCallTool().callTool() — return value |
Type-safe Hooks
Để useCallTool tự suy diễn kiểu dữ liệu input/output, export type IServer từ server và augment module @v-miniapp/ai/web trong file khai báo global.
Bước 1: Export type ở server entry point
const server = new McpServer()
.registerTool(searchProductsTool)
// ...
server.run()
export type IServer = typeof server
Bước 2: Augment module trong widget
import type { IServer } from '../../server/src'
declare module '@v-miniapp/ai/web' {
interface IDeclareServer {
server: IServer
}
}
export {}
Sau khi khai báo, useCallTool và callTool sẽ được kiểm tra kiểu tự động:
const { callTool } = useCallTool('search-products')
// ✅ Type-safe — TypeScript biết keyword là string
callTool({ keyword: 'áo thun' })
// ❌ TypeScript báo lỗi nếu truyền sai field
callTool({ unknownField: 'value' })
Đọc Thông Tin từ Host
useHostContext trả về object McpUiHostContext — toàn bộ thông tin môi trường từ host: ngôn ngữ, theme, platform, kích thước container, timezone, v.v.
import { useHostContext } from '@v-miniapp/ai/web'
function MyWidget() {
const ctx = useHostContext()
return (
<div>
<p>Ngôn ngữ: {ctx?.locale}</p>
<p>Theme: {ctx?.theme}</p>
<p>Platform: {ctx?.platform}</p>
</div>
)
}
Các thuộc tính thường dùng:
| Thuộc tính | Mô tả |
|---|---|
locale | Ngôn ngữ BCP 47 — dùng cho i18n |
theme | "light" hoặc "dark" |
platform | "web" / "desktop" / "mobile" |
timeZone | Timezone IANA (vd: "Asia/Ho_Chi_Minh") |
containerDimensions | Kích thước iframe container |
safeAreaInsets | Safe area insets trên mobile |
deviceCapabilities | { hover, touch } |
toolInfo | Metadata của tool đã mở widget |
Xem đầy đủ tài liệu và ví dụ tại Host Context.