
Creating an MCP Server with Apps SDK

Enrico Toniato
CTO
This guide walks you through creating a complete MCP server that supports OpenAI Apps SDK widgets, enabling rich interactive experiences in ChatGPT and other OpenAI-powered applications.
What You'll Build
By the end of this guide, you'll have:
- A fully functional MCP server with Apps SDK support
- Automatic widget registration from React components
- Tools that return interactive widgets
- 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 Apps SDK template:
npx create-mcp-use-app my-apps-sdk-server --template apps-sdk
cd my-apps-sdk-server
This creates a project structure:
my-apps-sdk-server/
├── resources/ # React widgets go here
│ └── display-weather.tsx # Example widget
├── index.ts # Server entry point
├── package.json
├── tsconfig.json
└── README.md
Step 2: Understanding the Server Setup
Let's examine the server entry point (index.ts):
import { createMCPServer } from "mcp-use/server";
const server = createMCPServer("my-apps-sdk-server", {
version: "1.0.0",
description: "MCP server with OpenAI Apps SDK integration",
// Optional: Set baseUrl for production CSP configuration
baseUrl: process.env.MCP_URL || "http://localhost:3000",
});
// Add your tools, resources, and prompts here
// ...
// Start the server
server.listen().then(() => {
console.log("Server running on http://localhost:3000");
});
Key Configuration Options
baseUrl: Required for production. Used to configure Content Security Policy (CSP) for Apps SDK widgetsversion: Server version for client discoverydescription: Human-readable server description
Step 3: Create Your First Widget
Widgets are React components in the resources/ folder. They're automatically registered as both MCP tools and resources.
Create resources/user-profile.tsx:
import React from "react";
import { z } from "zod";
import { useWidget } from "mcp-use/react";
// Define the props schema using Zod
const propSchema = z.object({
name: z.string().describe("User's full name"),
email: z.string().email().describe("User's email address"),
avatar: z.string().url().optional().describe("Avatar image URL"),
role: z.enum(["admin", "user", "guest"]).describe("User role"),
bio: z.string().optional().describe("User biography"),
});
// Export metadata for automatic registration
export const widgetMetadata = {
description: "Display a user profile card with avatar and information",
inputs: propSchema,
};
type UserProfileProps = z.infer<typeof propSchema>;
const UserProfile: React.FC = () => {
// useWidget hook provides props from Apps SDK
const { props, theme } = useWidget<UserProfileProps>();
const { name, email, avatar, role, bio } = props;
const bgColor = theme === "dark" ? "bg-gray-800" : "bg-white";
const textColor = theme === "dark" ? "text-white" : "text-gray-900";
const borderColor = theme === "dark" ? "border-gray-700" : "border-gray-200";
return (
<div
className={`max-w-md mx-auto ${bgColor} ${textColor} rounded-lg shadow-lg border ${borderColor} p-6`}
>
<div className="flex items-center space-x-4 mb-4">
{avatar ? (
<img
src={avatar}
alt={name}
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<div className="w-16 h-16 rounded-full bg-blue-500 flex items-center justify-center text-white text-2xl font-bold">
{name.charAt(0).toUpperCase()}
</div>
)}
<div className="flex-1">
<h2 className="text-xl font-bold">{name}</h2>
<p className="text-sm opacity-75">{email}</p>
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${
role === "admin"
? "bg-red-500 text-white"
: role === "user"
? "bg-blue-500 text-white"
: "bg-gray-500 text-white"
}`}
>
{role}
</span>
</div>
{bio && (
<div className="mt-4 pt-4 border-t border-gray-300">
<p className="text-sm">{bio}</p>
</div>
)}
</div>
);
};
export default UserProfile;
How Automatic Registration Works
When you call server.listen(), the framework:
- Scans the
resources/directory for.tsxfiles - Extracts
widgetMetadatafrom each component - Registers a tool with the filename as the name (e.g.,
user-profile) - Registers a resource at
ui://widget/user-profile.html - Builds the widget for Apps SDK compatibility
No manual registration needed!
Step 4: Add Traditional MCP Tools
You can mix automatic widgets with traditional tools:
// Fetch user data from an API
server.tool({
name: "get-user-data",
description: "Fetch user information from the database",
inputs: [
{ name: "userId", type: "string", required: true },
],
cb: async ({ userId }) => {
// Simulate API call
const userData = {
name: "John Doe",
email: "john@example.com",
avatar: "https://api.example.com/avatars/john.jpg",
role: "user",
bio: "Software developer passionate about AI",
};
return {
content: [
{
type: "text",
text: `User data retrieved for ${userId}`,
},
],
structuredContent: userData,
};
},
});
// Display user profile using the widget
// The LLM can now call 'user-profile' tool with the data
Step 5: Configure Apps SDK Metadata
For production widgets, you may want to customize Apps SDK metadata. You can do this manually:
server.uiResource({
type: "appsSdk",
name: "custom-widget",
title: "Custom Widget",
description: "A custom widget with specific configuration",
htmlTemplate: `<!DOCTYPE html>...`, // Your HTML
appsSdkMetadata: {
"openai/widgetDescription": "Interactive data visualization",
"openai/widgetCSP": {
connect_domains: ["https://api.example.com"],
resource_domains: ["https://cdn.example.com"],
},
"openai/toolInvocation/invoking": "Loading widget...",
"openai/toolInvocation/invoked": "Widget ready",
"openai/widgetAccessible": true,
"openai/resultCanProduceWidget": true,
},
});
However, with automatic registration, metadata is generated automatically based on your widgetMetadata.
Step 6: Testing Your Server
Start the Development Server
npm run dev
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
user-profiletool - Enter test parameters:
{
"name": "Jane Smith",
"email": "jane@example.com",
"role": "admin",
"bio": "Product manager and design enthusiast"
}
- Click Execute to see the widget render
Test in ChatGPT
- Configure your MCP server in ChatGPT settings
- Ask ChatGPT: "Show me a user profile for Jane Smith, email jane@example.com, role admin"
- ChatGPT will call the
user-profiletool and display the widget
Step 7: Advanced Widget Features
Accessing Tool Output
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>;
};
Calling Other Tools
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 8: Production Configuration
Environment Variables
Create a .env file:
PORT=3000
MCP_URL=https://your-server.com
NODE_ENV=production
Build for Production
npm run build
npm start
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:
const server = createMCPServer("my-server", {
baseUrl: process.env.MCP_URL, // Required for production
});
This ensures:
- Widget URLs use the correct domain
- CSP includes your server domain
- Works behind proxies and custom domains
Step 9: Deployment
Deploy to mcp-use Cloud
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_URLto your production domain
Best Practices
1. Schema Design
Use descriptive Zod schemas to help LLMs understand your widgets:
// ✅ Good: Clear descriptions
const propSchema = z.object({
city: z
.string()
.describe("The city name (e.g., 'New York', 'Tokyo')"),
temperature: z
.number()
.min(-50)
.max(60)
.describe("Temperature in Celsius"),
});
// ❌ Bad: No descriptions
const propSchema = z.object({
city: z.string(),
temp: z.number(),
});
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";
3. Error Handling
Handle missing or invalid props gracefully:
const MyWidget: React.FC = () => {
const { props } = useWidget<MyProps>();
if (!props.requiredField) {
return <div>Required data missing</div>;
}
return <div>{/* Render widget */}</div>;
};
4. Widget Focus
Keep widgets focused on a single purpose:
// ✅ Good: Single purpose
export const widgetMetadata = {
description: "Display weather for a city",
inputs: z.object({ city: z.string() }),
};
// ❌ Bad: Too many responsibilities
export const widgetMetadata = {
description: "Display weather, forecast, map, and news",
inputs: z.object({
/* too many fields */
}),
};
Troubleshooting
Widget Not Appearing
Problem: Widget file exists but tool doesn't appear
Solutions:
- Ensure file has
.tsxextension - Export
widgetMetadataobject - Export default React component
- Check server logs for errors
Props Not Received
Problem: Component receives empty props
Solutions:
- Use
useWidget()hook (not React props) - Ensure
widgetMetadata.inputsis a valid Zod schema - Verify tool parameters match schema
- Check Apps SDK is injecting
window.openai.toolInput
CSP Errors
Problem: Widget loads but assets fail with CSP errors
Solutions:
- Set
baseUrlin server config - Add external domains to CSP whitelist
- Use HTTPS for all resources
appsSdkMetadata: {
"openai/widgetCSP": {
connect_domains: ["https://api.example.com"],
resource_domains: ["https://cdn.example.com"],
},
}
Next Steps
- UI Widgets Overview - Deep dive into automatic widget registration
- Apps SDK Resources - Apps SDK primitives and metadata
- Project Templates - Explore available templates
- Deployment Guide - Deploy your server to production
Example: Complete Server
Here's a complete example combining everything:
import { createMCPServer } from "mcp-use/server";
const server = createMCPServer("weather-app", {
version: "1.0.0",
description: "Weather app with interactive widgets",
baseUrl: process.env.MCP_URL || "http://localhost:3000",
});
// Traditional tool to fetch weather data
server.tool({
name: "fetch-weather",
description: "Fetch current weather for a city",
inputs: [
{ name: "city", type: "string", required: true },
],
cb: async ({ city }) => {
// Simulate API call
const weather = {
city,
temperature: 22,
condition: "sunny",
humidity: 65,
};
return {
content: [
{
type: "text",
text: `Weather for ${city}: ${weather.condition}, ${weather.temperature}°C`,
},
],
structuredContent: weather,
};
},
});
// Widgets in resources/ folder are automatically registered
// - resources/display-weather.tsx
// - resources/weather-forecast.tsx
server.listen().then(() => {
console.log("Weather app server running!");
});
Summary
You've learned how to:
- ✅ Create an MCP server with Apps SDK support
- ✅ Use automatic widget registration
- ✅ Build React widgets with
useWidgethook - ✅ Configure Apps SDK metadata
- ✅ Test and deploy your server
Your MCP server is now ready to provide rich interactive experiences in ChatGPT!



