Apps & widgets
Render React interfaces inside an MCP host and connect them to your tools. Learn widget authoring, resource delivery and the current host limitations.
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