This guide walks you through creating a complete MCP server with interactive widget support.
What You’ll Build
By the end of this guide, you’ll have:
- A fully functional MCP server
- Tools that return widgets (using the
widget() helper)
- React widget components that receive props from tool handlers
- Production-ready configuration
Prerequisites
- Node.js 18+ installed
- Basic knowledge of TypeScript and React
- Familiarity with MCP concepts (see MCP 101)
Step 1: Create Your Project
The easiest way to start is using the mcp-apps template:
npx create-mcp-use-app my-widget-server --template mcp-apps
cd my-widget-server
This creates a project structure:
my-widget-server/
├── resources/ # React widgets go here
│ ├── product-search-result/ # Example widget (folder name = widget.name)
│ │ ├── widget.tsx # Main widget component
│ │ ├── components/ # Optional subcomponents
│ │ └── hooks/ # Optional hooks
│ └── styles.css
├── public/
│ ├── icon.svg
│ └── favicon.ico
├── index.ts # Server entry point
├── package.json
├── tsconfig.json
└── README.md
Type Generation: When you run mcp-use dev, TypeScript types are automatically generated from your tool definitions. This gives your widgets full IntelliSense for tool calls.
The template already has a widget-returning tool. Open index.ts and find the search-tools tool. Here’s what each part does:
server.tool(
{
name: "search-tools",
description: "Search for fruits and display the results in a visual widget",
schema: z.object({
query: z.string().optional().describe("Search query to filter fruits"),
}),
// Widget config — sets up metadata at registration time for Inspector and ChatGPT
widget: {
name: "product-search-result", // Must match resources/product-search-result/ folder
invoking: "Searching...", // Status text shown while the tool is running
invoked: "Results loaded", // Status text shown after the tool completes
},
},
async ({ query }) => {
const results = fruits.filter(
(f) => !query || f.fruit.toLowerCase().includes(query.toLowerCase())
);
// widget() — returns runtime data for the widget
return widget({
props: { query: query ?? "", results }, // Widget-only data (useWidget().props); not added to model context
output: text(`Found ${results.length} fruits matching "${query ?? "all"}"`), // What the model sees in the conversation
// metadata: { ... } — optional; extra data for useWidget().metadata (cursors, timestamps, etc.)
});
}
);
Summary:
widget: { name, invoking, invoked } — Configures which widget to render and status messages
widget({ props, output }) — props go to the widget via useWidget().props; output is shown to the model
The template already includes resources/product-search-result/widget.tsx. It receives props from the tool’s widget({ props }) return via useWidget():
// resources/product-search-result/widget.tsx (simplified — template has full implementation)
import React from "react";
import { useWidget } from "mcp-use/react";
type ProductSearchResultProps = {
query: string;
results: { fruit: string; color: string }[];
};
const ProductSearchResult: React.FC = () => {
const { props, isPending } = useWidget<ProductSearchResultProps>();
// Widget renders before tool completes — always check isPending first
if (isPending) {
return <div className="animate-pulse p-4">Loading...</div>;
}
const { query, results } = props;
return (
<div className="p-4 rounded-lg border">
<h3 className="font-semibold mb-2">
Results for "{query}" ({results.length} fruits)
</h3>
<ul className="space-y-2">
{results.map((r, i) => (
<li key={i}>{r.fruit}</li>
))}
</ul>
</div>
);
};
export default ProductSearchResult;
Handle loading state: The widget renders before the tool completes. On first render, props is empty and isPending is true. Always check isPending before accessing props.
You can mix widget-returning tools with traditional tools that return text or structured data. See Response Helpers for text(), object(), and other utilities:
import { object } from "mcp-use/server";
server.tool(
{
name: "get-product-details",
description: "Get detailed information about a product",
schema: z.object({
productId: z.string().describe("The product ID"),
}),
},
async ({ productId }) => {
// Your API call here
return object({
id: productId,
name: "Example Product",
price: 29,
description: "A great product",
});
}
);
Widgets can call other MCP tools. The useCallTool hook provides type-safe tool calling:
// Inside a widget component
const { callTool, data, isPending, isError } = useCallTool("get-product-details");
const handleViewDetails = () => {
callTool({ productId: "123" });
};
return (
<div>
<button onClick={handleViewDetails}>View details</button>
{isPending && <p>Loading...</p>}
{data && <p>{JSON.stringify(data.structuredContent)}</p>}
</div>
);
Type Safety: mcp-use dev generates types from your tool definitions. The callTool function gets autocomplete for tool names and parameters.
See useCallTool() for more patterns.
The widget config on your tool handles metadata for Inspector and ChatGPT. For custom CSP, borders, or other options, see Content Security Policy, MCP Apps, and UI Widgets.
Step 7: Testing Your Server
Start the Development Server
This starts:
- MCP server on port 3000
- Widget development server with Hot Module Replacement (HMR)
- Inspector UI at
http://localhost:3000/inspector
Test in Inspector
- Open
http://localhost:3000/inspector
- Navigate to the Tools tab
- Find your
search-tools tool
- Enter test parameters:
{ "query": "Widget" } or {}
- Click Execute to see the widget render
Deploy on Manufact
To test in ChatGPT, deploy your server first. See the Deployment Guide for one-command deployment to Manufact cloud.
Test in ChatGPT
- Configure your MCP server in ChatGPT settings (use your deployed URL)
- Ask: “Search for products” or “Show me product search results for Widget”
- ChatGPT will call
search-tools and display the widget
Widgets can access the output of their own tool execution:
const MyWidget: React.FC = () => {
const { props, output } = useWidget<MyProps, MyOutput>();
// props = tool input parameters
// output = additional data returned by the tool
return <div>{/* Use both props and output */}</div>;
};
Widgets can call other MCP tools:
const MyWidget: React.FC = () => {
const { callTool } = useWidget();
const handleAction = async () => {
const result = await callTool("get-user-data", {
userId: "123",
});
console.log(result);
};
return <button onClick={handleAction}>Fetch Data</button>;
};
Persistent State
Widgets can maintain state across interactions:
const MyWidget: React.FC = () => {
const { state, setState } = useWidget();
const savePreference = async () => {
await setState({ theme: "dark", language: "en" });
};
return <div>{/* Use state */}</div>;
};
Step 9: Production Configuration
Environment Variables
Create a .env file:
# Server port (default: 3000)
PORT=3000
# Production base URL; used for CSP and widget asset URLs — required for production
MCP_URL=https://your-server.com
# Environment mode (development | production)
NODE_ENV=production
See the CLI Reference for the full list of environment variables.
Build for Production
The build process:
- Compiles TypeScript
- Bundles React widgets for Apps SDK
- Optimizes assets
- Generates production-ready HTML templates
Content Security Policy
When baseUrl is set, CSP is automatically configured so widget URLs use the correct domain and your server domain is whitelisted. See Content Security Policy for per-widget configuration, environment variables, and troubleshooting.
Step 10: Deployment
Deploy to Manufact
The easiest deployment option:
# One command deployment
npx @mcp-use/cli deploy
See the Deployment Guide for details.
Manual Deployment
- Build your server:
npm run build
- Set environment variables
- Deploy to your hosting platform (Railway, Render, etc.)
- Update
MCP_URL to your production domain
Best Practices
1. Schema Design
Use descriptive Zod schemas on your tools to help LLMs understand parameters:
// ✅ Good: Clear descriptions
schema: z.object({
city: z.string().describe("The city name (e.g., 'New York', 'Tokyo')"),
units: z.enum(["celsius", "fahrenheit"]).describe("Temperature units"),
})
// ❌ Bad: No descriptions
schema: z.object({
city: z.string(),
u: z.string(),
})
2. Theme Support
Always support both light and dark themes:
const { theme } = useWidget();
const bgColor = theme === "dark" ? "bg-gray-900" : "bg-white";
const textColor = theme === "dark" ? "text-white" : "text-gray-900";
Troubleshooting
Problem: Tool runs but widget doesn’t render
Solutions:
- Ensure
widget.name in the tool config matches the folder name: resources/<widget.name>/widget.tsx
- Ensure the tool has
widget: { name, invoking, invoked } config
- Ensure the handler returns
widget({ props, output }), not text() or object() directly
- Check server logs for errors
Props Not Received
Problem: Component receives empty props
Solutions:
- Check
isPending first: Widgets render before the tool completes. Props are empty when isPending is true
- Ensure the handler passes data via
widget({ props: { ... } })
- Use
useWidget() hook (not React props)
const { props, isPending } = useWidget<MyProps>();
if (isPending) return <div>Loading...</div>;
// Now props are available
CSP Errors
Problem: Widget loads but assets fail with CSP errors
Solutions: Set baseUrl in server config, add external domains to CSP (per-widget or via CSP_URLS), and use HTTPS for all resources. See Content Security Policy for details.
Next Steps
Example: Complete Server
import { MCPServer, object, text, widget } from "mcp-use/server";
import { z } from "zod";
const server = new MCPServer({
name: "my-widget-server",
version: "1.0.0",
description: "MCP server with widget support",
baseUrl: process.env.MCP_URL || "http://localhost:3000",
});
// Tool that returns a widget
server.tool(
{
name: "search-tools",
description: "Search and display results",
schema: z.object({ query: z.string().optional() }),
widget: {
name: "product-search-result",
invoking: "Searching...",
invoked: "Results loaded",
},
},
async ({ query }) => {
const results = [/* fetch data */];
return widget({
props: { query: query ?? "", results },
output: text(`Found ${results.length} results`),
});
}
);
// Traditional tool (no widget)
server.tool(
{
name: "get-product-details",
description: "Get product details",
schema: z.object({ productId: z.string() }),
},
async ({ productId }) => object({ id: productId, name: "Widget", price: 29 })
);
server.listen().then(() => console.log("Server running"));
Summary
You’ve learned how to build MCP servers with widgets:
- ✅ Define tools with
widget: { name, invoking, invoked } and return widget({ props, output })
- ✅ Create widget components in
resources/<widget.name>/widget.tsx
- ✅ Use
useWidget() for props, theme, and loading state
- ✅ Use
useCallTool() for widgets that call other tools