Apps & widgets
Render React UI inside the host as an MCP App, linked to your tools.
Phase 2, partial
MCP Apps and widgets are Partial (Phase 2). Widgets render in-host and can call back into your server, and React-first authoring is in place. CSP minimization, dependency policy, and cross-host visual QA are still being hardened. Build with this in mind.
An MCP App is a React UI that the host (Claude, ChatGPT) renders alongside a tool result. In
Noodle Seed you author widgets as React components exposed as ui:// resources, linked to a tool.
There is no separate public widget primitive: React view metadata belongs on the tool whose result
renders it.
To see how Noodle Seed itself uses a local build-time widget and four remote operational widgets, read the developer plugin guide.
A tool with a widget
tool declares the tool and its optional view together. The view points at a React component file.
import { server, tool, z } from '@noodleseed/one';
export default server('ordering', { title: 'Ordering', version: '1.0.0' }, [
tool('open_ordering', {
description: 'Show the menu and render an ordering widget.',
input: z.object({ customer: z.string() }),
output: z.object({ menuText: z.string(), total: z.number() }),
fulfil: ({ input }) => ({ menuText: 'Falafel wrap', total: 12 }),
viewTitle: 'Food order',
view: { component: 'ordering-flow', entry: './views/ordering-flow.tsx' },
csp: { connectDomains: ['https://example.com'] },
}),
]);The React view
A view component imports typed helpers from @noodleseed/one/react to read the tool result and call
back into the server.
import { Feedback, generateHelpers } from '@noodleseed/one/react';
const { useToolInfo } = generateHelpers();
type OrderingResult = { readonly menuText: string };
function isOrderingResult(value: unknown): value is OrderingResult {
return (
value !== null &&
typeof value === 'object' &&
typeof (value as Partial<OrderingResult>).menuText === 'string' &&
(value as Partial<OrderingResult>).menuText!.length > 0
);
}
export default function OrderingFlow() {
const toolInfo = useToolInfo('open_ordering');
const pending = Object.keys(toolInfo).length === 0;
const result = isOrderingResult(toolInfo.structuredContent)
? toolInfo.structuredContent
: undefined;
if (pending) return <Feedback status="loading">Loading the menu…</Feedback>;
if (toolInfo.isError) return <Feedback status="error">Could not load the menu.</Feedback>;
if (!result) return <Feedback status="error">The menu result was incomplete.</Feedback>;
return <div>{result.menuText}</div>;
}Keep the complete useToolInfo() result. Treat an empty envelope as pending, handle isError
explicitly, and validate every required field or identifier before rendering it. A non-pending result
with invalid required data is a malformed-result error. Render result-dependent actions only after
validation succeeds; local form defaults must not masquerade as loaded business data.
Widget-only tools
A widget often needs actions the model should not call directly. Declare the same tool with
visibility: ['app']; the widget invokes it with callServerTool. These calls re-enter the
same authentication and policy path as any other tool call.
import { tool, z } from '@noodleseed/one';
tool('sync_cart', {
visibility: ['app'],
description: 'Sync a cart update from the ordering widget.',
input: z.object({ itemName: z.string(), quantity: z.number().int().min(1) }),
output: z.object({ total: z.number() }),
fulfil: ({ input }) => ({ total: input.quantity * 12 }),
});Raw HTML and handoff
view: { html }is the explicit lower-level escape hatch for self-contained markup. Prefer a Reactview: { component, entry }.handoffSession({ url, purpose, expiresAt })builds a typed envelope to hand the user off to an external URL (checkout, booking, payment) from a widget or tool result.
Preview locally
noodle check # MCP Apps / widget readiness checks (no service)
noodle devtools # preview widget metadata and rendering locally