@belgie/mcp
@belgie/mcp provides the browser-side pieces of a Belgie MCP App: a connected React widget,
typed MCP tool callers, host-context hooks, host actions, modal support, and a Vite plugin.
Use this page for TypeScript and React APIs. Use MCP Apps for Python tool registration, Belgie project dependencies, and the server development and production workflow.
Install
For a standalone TypeScript widget project, install the package, its MCP Apps peer dependency, and Vite:
npm install @belgie/mcp @modelcontextprotocol/ext-apps
npm install --save-dev vite
The package is ESM-only and requires Node.js 22 or newer for its development and CLI workflows.
The Python Belgie runtime itself does not require Node.js. In a Belgie Python project, declare the
same JavaScript dependencies in [tool.belgie.dependencies] and install them with belgie install.
Choose an import
| Import | Use |
|---|---|
@belgie/mcp |
Widget, mountWidget, tool-result hooks, host-context hooks, host actions, modals, and errors. |
@belgie/mcp/codegen |
Generate typed caller source programmatically with generateToolTypes(). |
@belgie/mcp/internal |
Runtime factories used by generated callers. Import this only when building compatible generated code. |
@belgie/mcp/vite |
The belgie() Vite plugin. |
@belgie/mcp/package.json |
Package metadata. |
The public application surface is @belgie/mcp. Generated files import their runtime helpers from
@belgie/mcp/internal; application code normally imports the generated functions and types instead.
Build a widget
Widgets are discovered below the configured source directory at <name>/widget.tsx. The file must
have a default export. The generated Vite entry imports that component and calls mountWidget for
you:
import { Widget } from "@belgie/mcp";
export default function Weather() {
return (
<Widget metadata={{ name: "Weather", version: "1.0.0" }}>
<main>Ready</main>
</Widget>
);
}
For a React entry that is mounted by another application, export the component instead and let the application render it:
import { Widget } from "@belgie/mcp";
export default function Weather() {
return (
<Widget metadata={{ name: "Weather", version: "1.0.0" }}>
<main>Ready</main>
</Widget>
);
}
Use mountWidget directly only when you own the HTML entry and are not using the discovered
<srcDir>/<name>/widget.tsx convention.
Widget connects to the MCP Apps host before rendering its children. Children that use host-bound
hooks or helpers must be descendants of Widget. Use fallback for the connecting state and
error for a connection error:
<Widget
metadata={{ name: "Weather", version: "1.0.0" }}
fallback={<p>Connecting to the host...</p>}
error={(error) => <p>Unable to connect: {error.message}</p>}
>
<WeatherView />
</Widget>
Configure the plugin in a normal Vite configuration:
import { belgie } from "@belgie/mcp/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [belgie({ srcDir: "src/widgets" })],
});
Development serves each widget at /widgets/<name>/index.html. The default production build
emits a self-contained file at dist/widgets/<name>/index.html for every widget. JavaScript, CSS,
fonts, images, and supported dynamic imports are inlined so the widget does not need a separate
asset server.
The plugin rejects duplicate widget names, missing default exports, retained JavaScript chunks, and
unsupported non-CSS assets in the default inline mode. The Python extension owns widget Path
registration and HTML delivery; this package owns the browser bundle and host bridge.
Share Vite assets
Use shared mode when the widget should participate in the existing Vite input graph and reuse host application code or dependencies:
export default defineConfig({
plugins: [belgie({ srcDir: "src/widgets", bundle: "shared" })],
base: "/assets/",
});
Shared mode leaves JavaScript, CSS, fonts, images, and dynamic-import chunks as normal Vite output.
The widget HTML remains under dist/widgets, references those assets through the configured
base, and must be served with the rest of the Vite output. Use the default inline mode when each
widget must be a completely self-contained HTML document.
Generate typed tool callers
Generate callers from the MCP server's tools/list response instead of hand-writing input and
output schemas:
npx belgie-mcp generate \
http://127.0.0.1:3001/mcp \
--output src/widgets/tools.ts
The command writes one camelCase function per tool and TypeScript declarations for its input and, when available, structured output. Commit the generated file. Vite and widget startup do not regenerate it, so the widget can build without contacting the MCP server.
Generated functions use the active connected widget by default and accept an explicit MCP Apps
App as the optional second argument:
import { getWeather } from "./tools";
const current = await getWeather({ city: "Austin" });
const fromExplicitApp = await getWeather({ city: "Austin" }, app);
Calls resolve to exactly one of these branches and do not reject for MCP, transport, context, or validation failures:
const response = await getWeather({ city: "Austin" });
if (response.error !== undefined) {
console.error(response.error.message);
} else {
console.log(response.result);
}
Tools with an MCP outputSchema produce a parsed, typed result validated with Zod. Tools without
an output schema return RawToolResult, which retains the complete MCP response, including
content, optional structuredContent, and _meta. MCP isError responses become
McpToolError; its result property keeps the raw error response.
The CLI supports streamable HTTP endpoints, automatic OAuth discovery and PKCE, direct headers, environment-backed headers, and deterministic freshness checks:
npx belgie-mcp generate https://example.com/mcp \
--header-env Authorization=AUTH_HEADER \
--output src/mcp-tools.ts \
--check \
--no-open
Use --no-oauth for an endpoint that must not attempt OAuth, --no-open to print the OAuth URL
instead of opening a browser, --header NAME:VALUE for a non-secret header, and repeatable
--header-env NAME=ENV_VAR options for secrets. --check fails when the output is missing or
stale without rewriting it.
If a build system needs to own generation, call generateToolTypes({ url, headers, oauth,
openBrowser }) from @belgie/mcp/codegen and write the returned TypeScript source itself.
Use tool results in a widget
useToolResult connects a generated caller to both the opening tool result and later executions:
import { Widget, useToolResult } from "@belgie/mcp";
import { getWeather } from "./tools";
function WeatherView() {
const { data, error, isLoading, isFetching, execute } = useToolResult(getWeather);
if (isLoading) {
return <p>Waiting for the tool result...</p>;
}
if (error !== undefined) {
return <p>{error.message}</p>;
}
return (
<section>
<p>{data?.summary ?? "No result"}</p>
<button disabled={isFetching} onClick={() => void execute({ city: "Austin" })}>
{isFetching ? "Refreshing..." : "Refresh"}
</button>
</section>
);
}
export default function Weather() {
return (
<Widget metadata={{ name: "Weather", version: "1.0.0" }}>
<WeatherView />
</Widget>
);
}
The hook returns data, error, rawResult, status, isLoading, isFetching, isSuccess,
isError, and execute. The opening result is consumed from the host without requiring a separate
event handler. execute() reuses the latest input, while execute(nextInput) replaces it for
later no-argument executions. Existing data remains visible while a refresh is in flight.
Direct generated calls and useToolResult are separate: a direct call does not update hook state,
and the hook does not add caching, retries, deduplication, or input-change revalidation.
For a schema-less tool, data is the raw MCP response. For a schema-backed tool, malformed
structured output is returned as an error. Opening cancellation becomes McpToolCancelledError.
The shipped MCP Apps example combines this hook with Widget, connection
fallbacks, raw-result inspection, and host actions:
import { Widget, openLink, sendLog, sendMessage, useToolResult } from "@belgie/mcp";
import { getTime } from "@widgets/tools";
import "@/global.css";
import { useState } from "react";
function buttonLabel(isLoading: boolean, isFetching: boolean): string {
if (isLoading) {
return "Waiting for Server Time...";
}
if (isFetching) {
return "Refreshing Server Time...";
}
return "Refresh Server Time";
}
function AppView() {
const {
data: timeData,
error: timeError,
rawResult,
status,
isLoading,
isFetching,
execute,
} = useToolResult(getTime);
const [message, setMessage] = useState("");
const [logMessage, setLogMessage] = useState("");
const [link, setLink] = useState("https://modelcontextprotocol.io");
return (
<main className="main">
<h2>Get Time Example</h2>
<div className="action">
<h3>Server Time</h3>
<p>
<span className="server-time">
{timeData?.time ?? (isLoading ? "Waiting for the opening tool result..." : "No time returned.")}
</span>
</p>
{timeError && <p className="notice">{timeError.message}</p>}
<p>
Status: {status}; raw response content blocks: {rawResult?.content.length ?? 0}
</p>
<button
disabled={isFetching}
onClick={() => void execute()}
>
{buttonLabel(isLoading, isFetching)}
</button>
</div>
<div className="action">
<h3>Send Message</h3>
<textarea
value={message}
onChange={(event) => {
setMessage(event.target.value);
}}
placeholder="Type a message..."
/>
<button
onClick={() => {
if (message.trim()) {
void sendMessage({ content: [{ type: "text", text: message }], role: "user" });
}
}}
>
Send Message
</button>
</div>
<div className="action">
<h3>Send Log</h3>
<input
value={logMessage}
onChange={(event) => {
setLogMessage(event.target.value);
}}
placeholder="Log message..."
/>
<button
onClick={() => {
if (logMessage.trim()) {
void sendLog({ data: logMessage, level: "info" });
}
}}
>
Send Log
</button>
</div>
<div className="action">
<h3>Open Link</h3>
<input
value={link}
onChange={(event) => {
setLink(event.target.value);
}}
placeholder="https://..."
/>
<button
onClick={() => {
if (link.trim()) {
void openLink({ url: link });
}
}}
>
Open Link
</button>
</div>
</main>
);
}
export default function GetTime() {
return (
<Widget
metadata={{ name: "Get Time", version: "1.0.0" }}
fallback={<div className="notice">Connecting...</div>}
error={(err) => (
<div className="notice">
<strong>ERROR:</strong> {err.message}
</div>
)}
>
<AppView />
</Widget>
);
}
Read host context
Use these hooks inside a connected <Widget> child. They subscribe to host-context changes:
| Hook | Returns |
|---|---|
useDisplayMode() |
[displayMode, setDisplayMode] for the current mode and a host request. |
useLayout() |
Container maxHeight and safe-area insets. |
useLocale() |
The host locale, defaulting to en-US. |
useTheme() |
The host theme, defaulting to light. |
useUserAgent() |
Normalized device type and input capabilities. |
useWidget() |
The active MCP Apps App object. |
import { useDisplayMode, useLayout, useLocale, useTheme, useUserAgent } from "@belgie/mcp";
function Environment() {
const [displayMode, setDisplayMode] = useDisplayMode();
const { maxHeight, safeArea } = useLayout();
const locale = useLocale();
const theme = useTheme();
const userAgent = useUserAgent();
return (
<section data-theme={theme} style={{ maxHeight, paddingTop: safeArea.insets.top }}>
<p>{locale}</p>
<p>{userAgent.device.type}</p>
<button onClick={() => void setDisplayMode("fullscreen")}>
{displayMode === "fullscreen" ? "Fullscreen" : "Expand"}
</button>
</section>
);
}
Call host actions
The context-bound helpers use the active widget automatically and preserve the MCP Apps method signatures:
import { openLink, sendLog, sendMessage, updateModelContext } from "@belgie/mcp";
async function notifyHost() {
await sendMessage({ role: "user", content: [{ type: "text", text: "Hello" }] });
await sendLog({ level: "info", data: "Sent a message" });
await openLink({ url: "https://modelcontextprotocol.io" });
await updateModelContext({ content: [{ type: "text", text: "The user opened the weather view." }] });
}
Other helpers include downloadFile, requestDisplayMode, and requestTeardown. If code already
holds a specific App, call that object's method directly instead of using a context-bound helper.
All context-bound helpers require a connected widget and throw when called outside one.
Open a modal
useModal() returns isOpen, the host-provided params, and an open callback:
import { useModal } from "@belgie/mcp";
function Cart() {
const { isOpen, params, open } = useModal();
if (isOpen) {
return <p>Confirm product {String(params?.productId)}</p>;
}
return (
<button onClick={() => open({ title: "Confirm", params: { productId: 42 } })}>
Add to cart
</button>
);
}
Use requestModal() and closeModal() for imperative code. Apps SDK hosts receive title,
template, and anchor; hosts without the Apps SDK use the in-iframe fallback, which applies
params and handles the backdrop and Escape key. Open modals from a user action such as a click,
not from a mount effect.
Develop the package
The package uses npm and keeps its lockfile in version control:
cd packages/mcp
npm ci
npm test
npm run check
npm pack --dry-run
npm test builds with tsdown, validates package metadata and declarations, runs the serialized
Vitest suite with V8 coverage, and checks the TypeScript API fixtures.