Creating an MCP Server with Apps SDK
Back to Blog
Tutorialdevelopmentapps-sdkwidgetstutorial

Creating an MCP Server with Apps SDK

Enrico Toniato

Enrico Toniato

CTO

November 14, 2024·9 min read
Share:

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 widgets
  • version: Server version for client discovery
  • description: 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:

  1. Scans the resources/ directory for .tsx files
  2. Extracts widgetMetadata from each component
  3. Registers a tool with the filename as the name (e.g., user-profile)
  4. Registers a resource at ui://widget/user-profile.html
  5. 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

  1. Open http://localhost:3000/inspector
  2. Navigate to the Tools tab
  3. Find your user-profile tool
  4. Enter test parameters:
{
  "name": "Jane Smith",
  "email": "jane@example.com",
  "role": "admin",
  "bio": "Product manager and design enthusiast"
}
  1. Click Execute to see the widget render

Test in ChatGPT

  1. Configure your MCP server in ChatGPT settings
  2. Ask ChatGPT: "Show me a user profile for Jane Smith, email jane@example.com, role admin"
  3. ChatGPT will call the user-profile tool 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

  1. Build your server: npm run build
  2. Set environment variables
  3. Deploy to your hosting platform (Railway, Render, etc.)
  4. Update MCP_URL to 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 .tsx extension
  • Export widgetMetadata object
  • 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.inputs is 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 baseUrl in 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

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 useWidget hook
  • ✅ Configure Apps SDK metadata
  • ✅ Test and deploy your server

Your MCP server is now ready to provide rich interactive experiences in ChatGPT!

Get Started

What will you build with MCP?

Start building AI agents with MCP servers today. Connect to any tool, automate any workflow, and deploy in minutes.