Noodle Seed
Guides

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.

src/server.ts
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.

src/views/ordering-flow.tsx
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 React view: { 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

On this page