MCP Server Development: Complete Guide to Model Context Protocol

The Model Context Protocol (MCP) is the architectural foundation of every ChatGPT app. Understanding MCP server development is essential for building production-ready ChatGPT applications that reach 800 million users.

This guide walks you through everything—from protocol fundamentals to deployment—with working code examples, performance optimization strategies, and OpenAI approval best practices.

Table of Contents

  1. MCP Protocol Fundamentals
  2. Architecture Patterns for Scalable Servers
  3. Implementing Tool Handlers
  4. Transport Layers: Streamable HTTP vs SSE
  5. Structured Content and Widget Integration
  6. Error Handling and Validation
  7. Testing with MCP Inspector
  8. Performance Optimization
  9. Security Best Practices
  10. Deployment Strategies
  11. Common Patterns and Advanced Techniques

What You'll Learn

This guide covers everything you need to build production-ready MCP servers:

  • Protocol Fundamentals: Understanding how ChatGPT communicates with your server
  • Architecture Patterns: Scalable design patterns for handling thousands of concurrent requests
  • Tool Handler Implementation: Writing safe, idempotent handlers with proper error handling
  • Transport Layers: Choosing between Streamable HTTP (recommended) and SSE
  • Widget Integration: Building rich interactive UIs within ChatGPT
  • Testing & Debugging: Using MCP Inspector and automated test suites
  • Performance Optimization: Keeping response payloads under 4k tokens
  • Security Best Practices: Protecting secrets, implementing OAuth 2.1, validating access tokens
  • Deployment Strategies: Going live on Cloud Functions, Docker, or Kubernetes
  • Advanced Patterns: Multi-tool composition, streaming operations, caching strategies

This is the knowledge base that separates successful ChatGPT apps from failed submissions. Let's build something great.


1. MCP Protocol Fundamentals

What is the Model Context Protocol?

The Model Context Protocol (MCP) is OpenAI's standard for connecting ChatGPT to external tools, APIs, and services. It enables ChatGPT to call your custom tools, process the results, and return rich structured responses to users.

Core Concept:

User → ChatGPT → MCP Server → External API/Database → Response → Widget/UI

MCP servers expose tools that ChatGPT can invoke. Each tool has:

  • Name: Unique identifier (e.g., searchClasses)
  • Description: What the tool does (for model discovery)
  • Input Schema: Required parameters (JSON Schema format)
  • Handler Function: Code that executes when tool is called

MCP Protocol Specification

The MCP spec defines the message format for communication between ChatGPT and your server:

{
  "type": "request",
  "id": "unique-request-id",
  "name": "searchClasses",
  "arguments": {
    "date": "2025-12-26",
    "type": "yoga",
    "minSpots": 1
  }
}

Your server responds with:

{
  "type": "response",
  "id": "unique-request-id",
  "content": [
    {
      "type": "text",
      "text": "Found 3 yoga classes available on December 26, 2025"
    },
    {
      "type": "structured",
      "data": [
        {
          "id": "class-123",
          "name": "Vinyasa Flow",
          "time": "10:00 AM",
          "instructor": "Sarah Chen",
          "spots": 5,
          "price": 25
        }
      ]
    },
    {
      "type": "widget",
      "mimeType": "text/html+skybridge",
      "content": "<div><!-- Rich UI for class booking --></div>"
    }
  ]
}

Key Principles:

  • Idempotent Handlers: Same input → Same output every time (safe for retries)
  • Atomic Tools: Each tool does ONE thing well
  • Structured Data: Return both text AND structured data for model clarity
  • Widget Support: Include rich HTML widgets (mimeType: "text/html+skybridge")

For complete protocol details, see the official MCP specification.


2. Architecture Patterns for Scalable Servers

Server Architecture Overview

A production MCP server has three layers:

Layer 1: Transport (Protocol handler)

  • HTTP listener
  • Message parsing/serialization
  • Streamable HTTP or SSE transport

Layer 2: Tool Registry (Router)

  • Tool registration
  • Input validation
  • Error handling

Layer 3: Handlers (Business logic)

  • API integrations
  • Database queries
  • External service calls

Recommended Architecture Pattern

MCP Server
├── Transport Layer
│   ├── HTTP Server (Express, Fastify, etc.)
│   ├── Streamable HTTP endpoint (/mcp/invoke)
│   └── CORS/Security middleware
├── Tool Registry
│   ├── Tool definitions (metadata)
│   ├── Input schemas (JSON Schema)
│   ├── Handler mapping
│   └── Error wrapper
└── Handler Layer
    ├── Database connections
    ├── API clients
    ├── Cache layer
    └── External integrations

Basic Server Implementation (Node.js)

// server.js
const express = require('express');
const cors = require('cors');
const app = express();

// 1. Define tools
const tools = [
  {
    name: 'searchClasses',
    description: 'Search fitness classes by date, time, or type',
    inputSchema: {
      type: 'object',
      properties: {
        date: { type: 'string', format: 'date' },
        type: { type: 'string', enum: ['yoga', 'pilates', 'boxing'] },
        minSpots: { type: 'integer', minimum: 1 }
      },
      required: ['date']
    }
  },
  {
    name: 'bookClass',
    description: 'Book a class for the user',
    inputSchema: {
      type: 'object',
      properties: {
        classId: { type: 'string' },
        memberId: { type: 'string' }
      },
      required: ['classId', 'memberId']
    }
  }
];

// 2. Tool handlers
const handlers = {
  searchClasses: async (args) => {
    const { date, type, minSpots = 1 } = args;
    const classes = await db.query(`
      SELECT * FROM classes
      WHERE date = $1
      AND type = $2
      AND spots >= $3
    `, [date, type, minSpots]);

    return {
      type: 'response',
      content: [
        { type: 'text', text: `Found ${classes.length} classes` },
        { type: 'structured', data: classes }
      ]
    };
  },

  bookClass: async (args) => {
    const { classId, memberId } = args;

    // Validate member exists
    const member = await db.query('SELECT * FROM members WHERE id = $1', [memberId]);
    if (!member) throw new Error('Member not found');

    // Check spots available
    const classData = await db.query('SELECT * FROM classes WHERE id = $1', [classId]);
    if (classData.spots < 1) throw new Error('Class is full');

    // Create booking
    const booking = await db.query(
      `INSERT INTO bookings (class_id, member_id) VALUES ($1, $2) RETURNING *`,
      [classId, memberId]
    );

    return {
      type: 'response',
      content: [
        { type: 'text', text: 'Class booked successfully!' },
        { type: 'structured', data: booking }
      ]
    };
  }
};

// 3. MCP endpoint
app.post('/mcp/invoke', express.json(), async (req, res) => {
  try {
    const { id, name, arguments: args } = req.body;

    // Validate tool exists
    const tool = tools.find(t => t.name === name);
    if (!tool) {
      return res.status(404).json({ error: `Tool ${name} not found` });
    }

    // Call handler
    const handler = handlers[name];
    const result = await handler(args);

    res.json({
      id,
      ...result
    });
  } catch (err) {
    res.status(500).json({
      id: req.body.id,
      error: err.message
    });
  }
});

// 4. Tool discovery endpoint
app.get('/mcp/tools', (req, res) => {
  res.json({ tools });
});

app.listen(3000, () => console.log('MCP Server running on port 3000'));

3. Implementing Tool Handlers

Tool Handler Best Practices

Principle 1: Keep Handlers Atomic

Each tool should handle ONE discrete task:

// ✅ GOOD: Atomic tools
- searchClasses (read-only, no side effects)
- bookClass (single write operation)
- getMemberProfile (read-only)
- cancelBooking (single delete operation)

// ❌ BAD: Monolithic tool
- manageMembership (searches, books, cancels, updates—does too much)

Principle 2: Input Validation

Validate all inputs before processing:

const validateInputs = (schema, args) => {
  const errors = [];

  for (const [key, prop] of Object.entries(schema.properties)) {
    // Check required fields
    if (schema.required.includes(key) && !(key in args)) {
      errors.push(`Missing required field: ${key}`);
    }

    // Type validation
    if (args[key] && typeof args[key] !== prop.type) {
      errors.push(`${key} must be ${prop.type}`);
    }

    // Enum validation
    if (prop.enum && !prop.enum.includes(args[key])) {
      errors.push(`${key} must be one of: ${prop.enum.join(', ')}`);
    }
  }

  return errors;
};

// In handler:
const handler = async (args) => {
  const errors = validateInputs(tool.inputSchema, args);
  if (errors.length > 0) {
    throw new Error(`Validation failed: ${errors.join('; ')}`);
  }

  // Process...
};

Principle 3: Error Handling

Return clear, actionable error messages:

class ToolError extends Error {
  constructor(message, code = 'TOOL_ERROR', details = {}) {
    super(message);
    this.code = code;
    this.details = details;
  }
}

// In handler:
if (classData.spots < 1) {
  throw new ToolError(
    'Class is fully booked',
    'CLASS_FULL',
    { classId, maxSpots: classData.capacity }
  );
}

// Response:
{
  "type": "error",
  "code": "CLASS_FULL",
  "message": "Class is fully booked",
  "details": { "classId": "123", "maxSpots": 20 }
}

Principle 4: Response Formatting

Always return structured data alongside text:

const response = {
  type: 'response',
  content: [
    // Text for model reasoning
    {
      type: 'text',
      text: `Successfully booked ${className} on ${date} at ${time}`
    },

    // Structured data for widgets and computation
    {
      type: 'structured',
      data: {
        bookingId: 'book-456',
        confirmationNumber: 'CONF-2025-123',
        className,
        date,
        time,
        instructor,
        location,
        price: 25,
        cancellationDeadline: '2025-12-25T10:00:00Z'
      }
    },

    // Rich HTML widget for visualization
    {
      type: 'widget',
      mimeType: 'text/html+skybridge',
      content: `
        <div style="padding: 16px; border: 1px solid #ddd; border-radius: 8px;">
          <h3>Booking Confirmed</h3>
          <p><strong>Confirmation:</strong> CONF-2025-123</p>
          <p><strong>Class:</strong> ${className}</p>
          <p><strong>Time:</strong> ${date} at ${time}</p>
          <button onclick="window.openai.setWidgetState({action: 'viewBooking'})">
            View Details
          </button>
        </div>
      `
    }
  ]
};

4. Transport Layers: Streamable HTTP vs SSE

Streamable HTTP (Recommended)

Streamable HTTP is the modern, recommended transport for MCP servers. It allows server-to-client streaming within standard HTTP.

Advantages:

  • Works through load balancers and proxies
  • Better for firewall-restricted environments
  • Single long-lived connection per request
  • Standard HTTP 1.1 (no WebSocket required)

Implementation:

app.post('/mcp/invoke', express.json(), async (req, res) => {
  const { id, name, arguments: args } = req.body;

  // Set headers for streaming
  res.setHeader('Content-Type', 'application/x-ndjson');
  res.setHeader('Transfer-Encoding', 'chunked');

  try {
    const tool = tools.find(t => t.name === name);
    if (!tool) {
      // Send error as newline-delimited JSON
      res.write(JSON.stringify({
        type: 'error',
        id,
        message: `Tool ${name} not found`
      }) + '\n');
      return res.end();
    }

    // Call handler (may send multiple responses)
    const handler = handlers[name];
    const result = await handler(args);

    // Send result as streaming response
    res.write(JSON.stringify({
      id,
      type: 'response',
      ...result
    }) + '\n');

    res.end();
  } catch (err) {
    res.write(JSON.stringify({
      id,
      type: 'error',
      message: err.message,
      code: err.code || 'INTERNAL_ERROR'
    }) + '\n');
    res.end();
  }
});

Server-Sent Events (SSE) - Legacy

SSE was the original MCP transport. It's still supported but Streamable HTTP is preferred.

Disadvantages of SSE:

  • Requires special EventSource API
  • Can't send request body (GET-only by default)
  • Less reliable through proxies
  • More complex client implementation

When to use SSE:

  • Legacy ChatGPT apps built before Streamable HTTP
  • When specifically required by deployment environment

5. Structured Content and Widget Integration

Widget Fundamentals

Widgets are rich HTML interfaces rendered in ChatGPT. They enhance UX beyond plain text.

Widget MIME Type: text/html+skybridge

{
  type: 'widget',
  mimeType: 'text/html+skybridge',
  content: '<div><!-- HTML content --></div>'
}

window.openai API

Widgets use the window.openai API to interact with ChatGPT:

// Get current widget state
const state = await window.openai.getWidgetState();

// Update widget state (triggers re-render)
await window.openai.setWidgetState({
  selectedClass: 'class-123',
  view: 'confirmation'
});

// Trigger handler from widget
const response = await window.openai.invokeHandler('bookClass', {
  classId: 'class-123',
  memberId: 'member-456'
});

Complete Widget Example

<!-- Fitness class booking widget -->
<div id="widget-root" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI';">
  <style>
    .class-card {
      border: 1px solid #e5e5e5;
      border-radius: 8px;
      padding: 16px;
      margin-bottom: 12px;
      cursor: pointer;
      transition: box-shadow 0.2s;
    }
    .class-card:hover {
      box-shadow: 0 4px 12px rgba(0,0,0,0.1);
    }
    .class-card.selected {
      border-color: #D4AF37;
      background: #fafaf9;
    }
    .class-time {
      font-weight: 600;
      font-size: 16px;
    }
    .class-info {
      font-size: 14px;
      color: #666;
      margin-top: 8px;
    }
    .button-group {
      margin-top: 16px;
      display: flex;
      gap: 12px;
    }
    .btn {
      padding: 10px 16px;
      border-radius: 6px;
      border: none;
      cursor: pointer;
      font-weight: 500;
      transition: background 0.2s;
    }
    .btn-primary {
      background: #D4AF37;
      color: #0A0E27;
    }
    .btn-primary:hover {
      background: #c19d1e;
    }
  </style>

  <script>
    let selectedClassId = null;

    async function selectClass(classId) {
      selectedClassId = classId;
      renderClasses(window.__classes);
    }

    async function handleBooking() {
      if (!selectedClassId) return;

      try {
        const response = await window.openai.invokeHandler('bookClass', {
          classId: selectedClassId,
          memberId: window.__memberId
        });

        // Show success state
        document.getElementById('booking-status').innerHTML =
          '<p style="color: green; font-weight: 500;">✓ Booking confirmed!</p>';
      } catch (err) {
        document.getElementById('booking-status').innerHTML =
          `<p style="color: red;">Error: ${err.message}</p>`;
      }
    }

    function renderClasses(classes) {
      const html = classes.map(cls => `
        <div class="class-card ${cls.id === selectedClassId ? 'selected' : ''}"
             onclick="selectClass('${cls.id}')">
          <div class="class-time">${cls.time} - ${cls.name}</div>
          <div class="class-info">
            <div>Instructor: ${cls.instructor}</div>
            <div>Spots available: ${cls.spots}</div>
            <div>Price: $${cls.price}</div>
          </div>
        </div>
      `).join('');

      document.getElementById('classes').innerHTML = html;
    }

    // Initialize
    window.addEventListener('load', () => {
      renderClasses(window.__classes);
    });
  </script>

  <div id="classes"></div>
  <div id="booking-status"></div>
  <div class="button-group">
    <button class="btn btn-primary" onclick="handleBooking()">
      Book Selected Class
    </button>
  </div>
</div>

Security Note: Never embed API keys or secrets in widget HTML. Always use server-side token validation.


6. Error Handling and Validation

Comprehensive Error Handling

class MCPError extends Error {
  constructor(message, code, httpStatus = 400, details = {}) {
    super(message);
    this.code = code;
    this.httpStatus = httpStatus;
    this.details = details;
  }
}

const errorTypes = {
  VALIDATION_ERROR: { code: 'VALIDATION_ERROR', status: 400 },
  NOT_FOUND: { code: 'NOT_FOUND', status: 404 },
  UNAUTHORIZED: { code: 'UNAUTHORIZED', status: 401 },
  RATE_LIMIT: { code: 'RATE_LIMIT', status: 429 },
  INTERNAL_ERROR: { code: 'INTERNAL_ERROR', status: 500 }
};

// Handler with error handling:
handlers.bookClass = async (args) => {
  try {
    // Validation
    if (!args.classId || !args.memberId) {
      throw new MCPError(
        'Missing required fields',
        'VALIDATION_ERROR',
        400,
        { required: ['classId', 'memberId'] }
      );
    }

    // Database query
    const booking = await db.query(
      `INSERT INTO bookings (class_id, member_id) VALUES ($1, $2) RETURNING *`,
      [args.classId, args.memberId]
    ).catch(err => {
      if (err.code === '23503') { // Foreign key violation
        throw new MCPError(
          'Class or member not found',
          'NOT_FOUND',
          404,
          { classId: args.classId, memberId: args.memberId }
        );
      }
      throw err;
    });

    return {
      type: 'response',
      content: [
        { type: 'text', text: 'Class booked successfully' },
        { type: 'structured', data: booking }
      ]
    };
  } catch (err) {
    if (err instanceof MCPError) {
      throw err;
    }
    throw new MCPError(
      'Booking failed',
      'INTERNAL_ERROR',
      500,
      { originalError: err.message }
    );
  }
};

// Express error handler:
app.use((err, req, res, next) => {
  const { message, code, httpStatus = 500, details } = err;

  res.status(httpStatus).json({
    error: {
      message,
      code,
      ...(process.env.NODE_ENV === 'development' && { details })
    }
  });
});

7. Testing with MCP Inspector

Setting Up MCP Inspector

MCP Inspector is the official testing tool for MCP servers.

# Install MCP Inspector
npm install -g @modelcontextprotocol/inspector

# Start your MCP server
npm start

# In another terminal, run MCP Inspector
mcp-inspector http://localhost:3000/mcp

MCP Inspector opens a web UI where you can:

  1. View all registered tools
  2. Test tool calls with sample inputs
  3. Inspect request/response payloads
  4. Validate schema compliance

Automated Testing

// test/mcp.test.js
const request = require('supertest');
const app = require('../server');

describe('MCP Server', () => {
  describe('Tool: searchClasses', () => {
    test('should return classes for valid date', async () => {
      const response = await request(app)
        .post('/mcp/invoke')
        .send({
          id: 'test-1',
          name: 'searchClasses',
          arguments: {
            date: '2025-12-26',
            type: 'yoga'
          }
        });

      expect(response.status).toBe(200);
      expect(response.body.type).toBe('response');
      expect(Array.isArray(response.body.content)).toBe(true);
    });

    test('should handle missing date parameter', async () => {
      const response = await request(app)
        .post('/mcp/invoke')
        .send({
          id: 'test-2',
          name: 'searchClasses',
          arguments: { type: 'yoga' } // missing date
        });

      expect(response.status).toBe(400);
      expect(response.body.error.code).toBe('VALIDATION_ERROR');
    });
  });

  describe('Tool: bookClass', () => {
    test('should create booking with valid inputs', async () => {
      const response = await request(app)
        .post('/mcp/invoke')
        .send({
          id: 'test-3',
          name: 'bookClass',
          arguments: {
            classId: 'class-123',
            memberId: 'member-456'
          }
        });

      expect(response.status).toBe(200);
      expect(response.body.type).toBe('response');
    });

    test('should return error for non-existent member', async () => {
      const response = await request(app)
        .post('/mcp/invoke')
        .send({
          id: 'test-4',
          name: 'bookClass',
          arguments: {
            classId: 'class-123',
            memberId: 'nonexistent-member'
          }
        });

      expect(response.status).toBe(404);
      expect(response.body.error.code).toBe('NOT_FOUND');
    });
  });
});

8. Performance Optimization

Response Payload Size

ChatGPT enforces a 4,000 token limit on widget responses. Keep payloads small:

// ✅ GOOD: Optimized response (~500 tokens)
{
  type: 'response',
  content: [
    {
      type: 'text',
      text: 'Found 3 classes'
    },
    {
      type: 'structured',
      data: [
        { id: 'c1', name: 'Yoga', time: '10:00', spots: 5 },
        { id: 'c2', name: 'Pilates', time: '11:00', spots: 3 },
        { id: 'c3', name: 'Boxing', time: '14:00', spots: 8 }
      ]
    }
  ]
}

// ❌ BAD: Oversized response (~8000 tokens)
{
  type: 'response',
  content: [
    { type: 'text', text: 'Very detailed analysis...' }, // 2000 tokens
    { type: 'structured', data: [/* 100 classes with full details */] }, // 5000 tokens
    { type: 'widget', content: '<div><!-- excessive HTML --></div>' } // 1000 tokens
  ]
}

Caching Strategies

const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 300 }); // 5-minute TTL

handlers.searchClasses = async (args) => {
  // Create cache key from arguments
  const cacheKey = `classes:${args.date}:${args.type}`;

  // Check cache
  const cached = cache.get(cacheKey);
  if (cached) return cached;

  // Query database
  const classes = await db.query(/* ... */);

  // Cache result
  cache.set(cacheKey, classes);

  return formatResponse(classes);
};

Database Query Optimization

// ❌ N+1 Query Problem
handlers.getClassesWithInstructors = async () => {
  const classes = await db.query('SELECT * FROM classes');

  // Separate query for each class (slow!)
  for (const cls of classes) {
    cls.instructor = await db.query(
      'SELECT * FROM instructors WHERE id = $1',
      [cls.instructor_id]
    );
  }
  return classes;
};

// ✅ Optimized with JOIN
handlers.getClassesWithInstructors = async () => {
  return db.query(`
    SELECT c.*, i.name as instructor_name
    FROM classes c
    LEFT JOIN instructors i ON c.instructor_id = i.id
  `);
};

9. Security Best Practices

Never Expose Secrets

// ❌ WRONG: Secrets exposed in response
{
  type: 'widget',
  content: `<script>
    const apiKey = '${process.env.DATABASE_URL}'; // LEAK!
    const mindbodyToken = '${mindbodyApiKey}'; // LEAK!
  </script>`
}

// ✅ CORRECT: Use server-side calls only
// Client widget calls server handler, which uses secrets
handlers.getMemberClasses = async (args) => {
  const member = await db.query(
    'SELECT * FROM members WHERE id = $1',
    [args.memberId]
  ); // Uses DB credentials (server-side only)

  return formatResponse(member);
};

OAuth 2.1 with PKCE

For authenticated apps, implement OAuth 2.1:

const crypto = require('crypto');
const base64url = require('base64-url');

// Step 1: Generate code verifier and challenge
app.get('/auth/init', (req, res) => {
  const codeVerifier = base64url.escape(
    crypto.randomBytes(32).toString('base64')
  );
  const codeChallenge = base64url.escape(
    crypto.createHash('sha256')
      .update(codeVerifier)
      .digest('base64')
  );

  // Store verifier in session (server-side)
  req.session.codeVerifier = codeVerifier;

  res.json({
    authUrl: `https://oauth-provider.com/authorize?` +
      `client_id=${process.env.OAUTH_CLIENT_ID}&` +
      `code_challenge=${codeChallenge}&` +
      `code_challenge_method=S256&` +
      `redirect_uri=https://chatgpt.com/connector_platform_oauth_redirect`
  });
});

// Step 2: Exchange code for token
app.post('/auth/token', async (req, res) => {
  const { code } = req.body;
  const codeVerifier = req.session.codeVerifier;

  const tokenResponse = await fetch('https://oauth-provider.com/token', {
    method: 'POST',
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code,
      code_verifier: codeVerifier,
      client_id: process.env.OAUTH_CLIENT_ID,
      client_secret: process.env.OAUTH_CLIENT_SECRET
    })
  });

  const { access_token } = await tokenResponse.json();

  // Store token securely (httpOnly cookie, encrypted database)
  res.cookie('access_token', access_token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax'
  });

  res.json({ success: true });
});

// Step 3: Verify token in handlers
handlers.getMemberProfile = async (args) => {
  const token = args.accessToken; // From client

  // Verify token signature, expiration, scopes
  const decoded = jwt.verify(token, process.env.OAUTH_PUBLIC_KEY);
  if (!decoded.scope.includes('profile:read')) {
    throw new MCPError('Insufficient permissions', 'UNAUTHORIZED', 401);
  }

  return formatResponse(decoded);
};

10. OpenAI Approval Checklist

Before submitting your ChatGPT app to OpenAI, ensure your MCP server passes these critical requirements:

1. Conversational Value

Your app must leverage ChatGPT's strengths:

  • Natural Language Processing: Use ChatGPT's ability to understand conversational intent
  • Context Awareness: Leverage multi-turn conversation history
  • Semantic Understanding: Your tools should handle nuanced user requests

Example: Good Conversational Value

User: "Find me a yoga class tomorrow evening with Sarah Chen where I can bring a friend"

ChatGPT understands:
- Tomorrow evening (time range inference)
- Sarah Chen (instructor name)
- 2 spots needed (friend inference)

Your tool receives: searchClasses({
  date: 'tomorrow',
  timeRange: 'evening',
  instructor: 'Sarah Chen',
  minSpots: 2
})

2. Beyond Base ChatGPT

Your app must provide value that ChatGPT alone cannot:

Does NOT Provide Beyond Base ChatGPT:

  • Simple FAQ (ChatGPT already knows this)
  • General information about fitness
  • Generic writing assistance

DOES Provide Beyond Base ChatGPT:

  • Real-time class availability (needs database)
  • Member-specific booking capability (needs authentication)
  • Live pricing and spot availability (needs API)

3. Atomic, Model-Friendly Actions

Each tool must be:

  • Self-contained: Completes one discrete action
  • Explicit: Clear inputs and outputs
  • Deterministic: Same input → same output
  • Retryable: Safe for ChatGPT to call multiple times

4. Rich UI Only When Needed

Widgets should enhance, not replace, text:

// ✅ GOOD: Widget enhances text
{
  type: 'text',
  text: 'Found 3 yoga classes available. Here are the details:'
},
{
  type: 'widget',
  content: '<div><!-- Interactive class selector --></div>'
  // User could understand without widget, but widget makes selection easier
}

// ❌ BAD: Widget is required to understand
{
  type: 'widget',
  content: '<div><!-- No text summary, just widget --></div>'
  // User can't understand without widget
}

5. End-to-End In-Chat Completion

Users should complete at least one meaningful task without leaving ChatGPT:

Good Example:

  1. User: "Book me a yoga class tomorrow"
  2. ChatGPT: "Which instructor?"
  3. User: "Sarah Chen"
  4. ChatGPT calls: searchClasses({instructor: 'Sarah Chen'})
  5. User selects class via widget
  6. ChatGPT calls: bookClass({classId: 'class-123'})
  7. Confirmation: "Booking confirmed! Confirmation #CONF-2025-123"
  8. User never leaves ChatGPT

Bad Example:

  1. User: "Book me a yoga class"
  2. ChatGPT: "Click here to book" [links to external website]
  3. User leaves ChatGPT to complete booking (FAIL)

6. Performance Requirements

  • Response latency: < 4 seconds (users expect ChatGPT speed)
  • Payload size: < 4,000 tokens (widget + structured data)
  • Error handling: Clear error messages (no technical jargon)

7. Security Validation

OpenAI will audit your MCP server:

// ✅ PASS: Secure token handling
app.post('/mcp/invoke', async (req, res) => {
  // Access token comes in request body
  const token = req.body.accessToken;

  // Validate signature
  const decoded = jwt.verify(token, process.env.PUBLIC_KEY);

  // Verify scopes
  if (!decoded.scopes.includes('class:book')) {
    throw new MCPError('Insufficient permissions', 'UNAUTHORIZED', 401);
  }

  // Continue with booking
});

// ❌ FAIL: Exposed secrets
app.post('/mcp/invoke', async (req, res) => {
  // Secret leaked in widget HTML
  return {
    type: 'widget',
    content: `<script>const apiKey = '${process.env.API_KEY}';</script>`
  };
});

Common Rejection Reasons (And How to Avoid Them)

Reason Why It Fails How to Fix
"Just a website in a widget" Doesn't leverage ChatGPT Build conversational tool calling, not website embedding
"No conversational value" Could use ChatGPT alone Require real-time data or authentication your app provides
"Complex multi-step workflow" Too many steps for UI Break into atomic tools, handle multi-step via tool composition
"Misleading error messages" User confusion Return clear, actionable errors ("Class is full" vs "Error 500")
"Exposed API keys" Security risk Never embed secrets; validate tokens server-side
"More than 2 CTAs per card" UI clutter Keep widgets simple; one action per card
"Custom fonts" Violates design system Use only system fonts (SF Pro, Roboto)
"Nested scrolling" Poor UX in ChatGPT Limit card height, use pagination instead

10. Deployment Strategies

Local Development

# Start dev server with hot reload
npm run dev

# Test with MCP Inspector
mcp-inspector http://localhost:3000/mcp

Production Deployment

Option 1: Cloud Functions (Recommended)

# Deploy to Google Cloud Functions
export GOOGLE_APPLICATION_CREDENTIALS=.vault/service-account-key.json

firebase deploy --only functions --project gbp2025-5effc

Option 2: Docker + Cloud Run

FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm install --production

COPY . .
ENV PORT=8080
EXPOSE 8080

CMD ["npm", "start"]
# Build and deploy
docker build -t mcp-server .
gcloud run deploy mcp-server --image mcp-server:latest

Option 3: Kubernetes

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mcp-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: mcp-server
  template:
    metadata:
      labels:
        app: mcp-server
    spec:
      containers:
      - name: mcp-server
        image: mcp-server:latest
        ports:
        - containerPort: 3000
        env:
        - name: NODE_ENV
          value: "production"
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: mcp-secrets
              key: database-url

11. Common Patterns and Advanced Techniques

Understanding Tool Discoverability

ChatGPT uses tool descriptions and input schemas to decide when to invoke your tools. Writing clear descriptions dramatically improves discoverability:

// ❌ BAD: Vague description
{
  name: 'searchClasses',
  description: 'Search for classes', // Too generic
  inputSchema: { /* ... */ }
}

// ✅ GOOD: Specific, action-oriented
{
  name: 'searchClasses',
  description: 'Search fitness classes by date, time, instructor, or type. Returns available spots and pricing. Use this when user wants to find classes.',
  inputSchema: {
    type: 'object',
    properties: {
      date: {
        type: 'string',
        format: 'date',
        description: 'Date to search (e.g., "2025-12-26")'
      },
      type: {
        type: 'string',
        enum: ['yoga', 'pilates', 'boxing', 'cycling'],
        description: 'Type of class to search for'
      },
      minSpots: {
        type: 'integer',
        minimum: 1,
        description: 'Minimum number of available spots required'
      },
      maxPrice: {
        type: 'number',
        description: 'Maximum price per class (optional)'
      }
    },
    required: ['date'],
    description: 'Parameters for class search. Only date is required.'
  }
}

Guidelines for Better Discoverability:

  1. Specific descriptions: Describe exactly what tool does, not just the function name
  2. Usage guidance: Explain when ChatGPT should use this tool vs others
  3. Example parameters: Show what good input looks like
  4. Return value documentation: Describe the structured data returned
  5. Constraints: Clearly document limitations (max results, rate limits, time windows)

Tool Composition Patterns

Complex workflows often require multiple tool calls in sequence:

// Pattern: Sequential composition
// User: "Show me Sarah Chen's afternoon yoga classes next week"
// 1. searchInstructors('Sarah Chen') → { id: 'sarah-123', name: 'Sarah Chen' }
// 2. searchClasses({ instructor_id: 'sarah-123', type: 'yoga', dateRange: 'next week', timeRange: 'afternoon' })
// 3. Return combined results

// Pattern: Conditional branching
// User: "If there's a pilates class tomorrow with spots available, book me the first one"
// 1. searchClasses({ date: 'tomorrow', type: 'pilates' })
// 2. IF spots > 0: bookClass({ classId: result[0].id, memberId: user.id })
// 3. ELSE: Return "No pilates classes available"

// Pattern: Parallel composition
// User: "Show me available morning yoga classes and evening pilates classes this week"
// 1. searchClasses({ type: 'yoga', timeRange: 'morning' }) — parallel
// 2. searchClasses({ type: 'pilates', timeRange: 'evening' }) — parallel
// 3. Return combined results

Handling Idempotency

Idempotency is critical: same input should always produce same output, even if called multiple times.

// ✅ IDEMPOTENT: Safe to retry
handlers.getInstructorProfile = async (args) => {
  const { instructorId } = args;
  const instructor = await db.query(
    'SELECT * FROM instructors WHERE id = $1',
    [instructorId]
  );
  return instructor;
};

// ❌ NOT IDEMPOTENT: Calling twice creates two bookings
handlers.bookClass = async (args) => {
  const { classId, memberId } = args;
  // If ChatGPT retries, this creates duplicate bookings!
  const booking = await db.query(
    'INSERT INTO bookings (class_id, member_id) VALUES ($1, $2)',
    [classId, memberId]
  );
  return booking;
};

// ✅ IDEMPOTENT: Check for existing booking before creating
handlers.bookClass = async (args) => {
  const { classId, memberId } = args;

  // Check if booking already exists
  const existing = await db.query(
    'SELECT * FROM bookings WHERE class_id = $1 AND member_id = $2',
    [classId, memberId]
  );

  if (existing) {
    return existing; // Return existing booking instead of creating duplicate
  }

  const booking = await db.query(
    'INSERT INTO bookings (class_id, member_id) VALUES ($1, $2)',
    [classId, memberId]
  );
  return booking;
};

Multi-Tool Composition

Chain multiple tools for complex workflows:

// User: "Book me the first available yoga class with Sarah Chen"
// ChatGPT calls tools in sequence:

// 1. searchInstructors('Sarah Chen') → instructor_id: 'sarah-123'
// 2. searchClasses({ instructor_id: 'sarah-123', type: 'yoga' }) → [class-001, class-002]
// 3. bookClass({ classId: 'class-001', memberId: 'member-456' }) → booking confirmed

Versioning and Backward Compatibility

As your MCP server evolves, maintain backward compatibility:

// Version endpoints to support multiple API versions
app.post('/mcp/v1/invoke', async (req, res) => {
  // Current API version
  handleMCPRequest(req, res, 'v1');
});

app.post('/mcp/v2/invoke', async (req, res) => {
  // New version with breaking changes
  handleMCPRequest(req, res, 'v2');
});

const handleMCPRequest = (req, res, version) => {
  const { id, name, arguments: args } = req.body;

  if (version === 'v1') {
    // Old response format
    res.json({ id, data: result, success: true });
  } else if (version === 'v2') {
    // New response format (MCP standard)
    res.json({ id, type: 'response', content: [/* ... */] });
  }
};

Monitoring and Observability

Production MCP servers need comprehensive monitoring:

const prometheus = require('prom-client');

// Define metrics
const toolCallCounter = new prometheus.Counter({
  name: 'mcp_tool_calls_total',
  help: 'Total MCP tool calls',
  labelNames: ['tool_name', 'status']
});

const toolCallDuration = new prometheus.Histogram({
  name: 'mcp_tool_call_duration_seconds',
  help: 'MCP tool call duration',
  labelNames: ['tool_name']
});

// Instrument handlers
handlers.searchClasses = async (args) => {
  const start = Date.now();

  try {
    const result = await db.query(/* ... */);
    toolCallCounter.labels('searchClasses', 'success').inc();
    toolCallDuration.labels('searchClasses').observe((Date.now() - start) / 1000);
    return result;
  } catch (err) {
    toolCallCounter.labels('searchClasses', 'error').inc();
    throw err;
  }
};

// Expose metrics endpoint
app.get('/metrics', (req, res) => {
  res.set('Content-Type', prometheus.register.contentType);
  res.end(prometheus.register.metrics());
});

Request Tracing for Debugging

Implement request tracing to follow requests through your system:

const { v4: uuidv4 } = require('uuid');

app.use((req, res, next) => {
  req.traceId = req.headers['x-trace-id'] || uuidv4();
  res.setHeader('X-Trace-ID', req.traceId);

  console.log(`[${req.traceId}] ${req.method} ${req.path}`, {
    timestamp: new Date().toISOString(),
    headers: req.headers
  });

  next();
});

// Pass trace ID through layers
handlers.bookClass = async (args, context) => {
  const { traceId } = context;

  logger.info(`[${traceId}] Checking class availability`, { classId: args.classId });

  const classData = await db.query(
    'SELECT * FROM classes WHERE id = $1',
    [args.classId],
    { traceId } // Pass through database client
  );

  logger.info(`[${traceId}] Creating booking`, { spots: classData.spots });

  const booking = await db.query(/* ... */, { traceId });

  logger.info(`[${traceId}] Booking created`, { bookingId: booking.id });

  return booking;
};

Streaming Long-Running Operations

For operations that take >10 seconds:

handlers.generateSchedule = async (args) => {
  const { memberId, weekCount = 4 } = args;

  // Start long operation in background
  const taskId = crypto.randomUUID();

  // Return immediately with task ID
  const response = {
    type: 'response',
    content: [
      { type: 'text', text: `Generating ${weekCount}-week schedule...` },
      { type: 'widget', mimeType: 'text/html+skybridge', content: `
        <div>
          <p>Processing... <span id="progress">0%</span></p>
          <script>
            async function pollProgress() {
              const res = await fetch('/api/task/${taskId}');
              const data = await res.json();
              document.getElementById('progress').textContent = data.progress + '%';

              if (data.complete) {
                location.reload(); // Refresh with results
              } else {
                setTimeout(pollProgress, 1000);
              }
            }
            pollProgress();
          </script>
        </div>
      ` }
    ]
  };

  // Process in background
  processScheduleAsync(memberId, weekCount, taskId);

  return response;
};

Real-World Example: Complete Fitness Studio MCP Server

Let's put everything together with a complete, production-ready MCP server for a fitness studio:

Complete Implementation

// server.js - Production MCP Server
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const jwt = require('jsonwebtoken');
const { Pool } = require('pg');

const app = express();

// Security middleware
app.use(helmet());
app.use(cors({
  origin: ['https://chatgpt.com', 'https://platform.openai.com'],
  credentials: true
}));
app.use(express.json());

// Database setup
const db = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: { rejectUnauthorized: false }
});

// ============= TOOL DEFINITIONS =============

const tools = [
  {
    name: 'searchClasses',
    description: 'Search fitness classes by date, time, type, or instructor. Returns available classes with pricing and spot availability.',
    inputSchema: {
      type: 'object',
      properties: {
        date: { type: 'string', format: 'date', description: 'Date to search (YYYY-MM-DD)' },
        type: { type: 'string', enum: ['yoga', 'pilates', 'boxing', 'cycling', 'spin'], description: 'Class type' },
        timeRange: { type: 'string', enum: ['morning', 'afternoon', 'evening'], description: 'Time of day' },
        instructor: { type: 'string', description: 'Instructor name (optional)' },
        minSpots: { type: 'integer', minimum: 1, description: 'Minimum available spots' }
      },
      required: ['date']
    }
  },
  {
    name: 'bookClass',
    description: 'Book a fitness class for the authenticated member. Returns booking confirmation with class details.',
    inputSchema: {
      type: 'object',
      properties: {
        classId: { type: 'string', description: 'Class ID to book' },
        accessToken: { type: 'string', description: 'Member OAuth access token' }
      },
      required: ['classId', 'accessToken']
    }
  },
  {
    name: 'getMemberProfile',
    description: 'Get the authenticated member's profile and membership status.',
    inputSchema: {
      type: 'object',
      properties: {
        accessToken: { type: 'string', description: 'Member OAuth access token' }
      },
      required: ['accessToken']
    }
  }
];

// ============= TOOL HANDLERS =============

const handlers = {
  searchClasses: async (args) => {
    const { date, type, timeRange, instructor, minSpots = 1 } = args;

    // Build query
    let query = `
      SELECT c.*, i.name as instructor_name, i.image_url as instructor_image
      FROM classes c
      LEFT JOIN instructors i ON c.instructor_id = i.id
      WHERE c.date = $1 AND c.spots >= $2
    `;
    const params = [date, minSpots];

    if (type) {
      query += ` AND c.type = $${params.length + 1}`;
      params.push(type);
    }

    if (timeRange) {
      const timeRanges = {
        morning: { start: '06:00', end: '12:00' },
        afternoon: { start: '12:00', end: '17:00' },
        evening: { start: '17:00', end: '22:00' }
      };
      const range = timeRanges[timeRange];
      query += ` AND c.start_time >= $${params.length + 1} AND c.start_time < $${params.length + 2}`;
      params.push(range.start, range.end);
    }

    if (instructor) {
      query += ` AND LOWER(i.name) LIKE LOWER($${params.length + 1})`;
      params.push(`%${instructor}%`);
    }

    query += ' ORDER BY c.start_time ASC LIMIT 10';

    const result = await db.query(query, params);

    if (result.rows.length === 0) {
      return {
        type: 'response',
        content: [
          { type: 'text', text: 'No classes available matching your criteria.' }
        ]
      };
    }

    return {
      type: 'response',
      content: [
        {
          type: 'text',
          text: `Found ${result.rows.length} ${type || 'fitness'} classes available on ${date}.`
        },
        {
          type: 'structured',
          data: result.rows.map(cls => ({
            id: cls.id,
            name: cls.name,
            type: cls.type,
            time: cls.start_time,
            instructor: cls.instructor_name,
            spots: cls.spots,
            price: cls.price,
            level: cls.level
          }))
        }
      ]
    };
  },

  bookClass: async (args) => {
    const { classId, accessToken } = args;

    try {
      // Validate token
      const decoded = jwt.verify(accessToken, process.env.PUBLIC_KEY);
      const memberId = decoded.sub;

      // Check class exists
      const classResult = await db.query(
        'SELECT * FROM classes WHERE id = $1',
        [classId]
      );

      if (classResult.rows.length === 0) {
        throw new Error('Class not found');
      }

      const cls = classResult.rows[0];

      // Check spots available
      if (cls.spots < 1) {
        throw new Error('Class is fully booked');
      }

      // Check for existing booking (idempotency)
      const existing = await db.query(
        'SELECT * FROM bookings WHERE class_id = $1 AND member_id = $2',
        [classId, memberId]
      );

      if (existing.rows.length > 0) {
        const booking = existing.rows[0];
        return {
          type: 'response',
          content: [
            { type: 'text', text: 'You are already booked for this class!' },
            {
              type: 'structured',
              data: {
                bookingId: booking.id,
                confirmationNumber: booking.confirmation_number,
                className: cls.name,
                date: cls.date,
                time: cls.start_time,
                instructor: cls.instructor_name,
                location: cls.location,
                cancellationDeadline: booking.cancellation_deadline
              }
            }
          ]
        };
      }

      // Create booking
      const booking = await db.query(
        `INSERT INTO bookings (class_id, member_id, confirmation_number)
         VALUES ($1, $2, $3)
         RETURNING *`,
        [classId, memberId, `CONF-${Date.now()}`]
      );

      // Decrease spots
      await db.query(
        'UPDATE classes SET spots = spots - 1 WHERE id = $1',
        [classId]
      );

      const booked = booking.rows[0];

      return {
        type: 'response',
        content: [
          {
            type: 'text',
            text: `✓ Booked! You're confirmed for ${cls.name} on ${cls.date} at ${cls.start_time}.`
          },
          {
            type: 'structured',
            data: {
              bookingId: booked.id,
              confirmationNumber: booked.confirmation_number,
              className: cls.name,
              date: cls.date,
              time: cls.start_time,
              instructor: cls.instructor_name,
              location: cls.location,
              cancellationDeadline: booked.cancellation_deadline
            }
          }
        ]
      };
    } catch (err) {
      throw new Error(`Booking failed: ${err.message}`);
    }
  },

  getMemberProfile: async (args) => {
    const { accessToken } = args;

    try {
      const decoded = jwt.verify(accessToken, process.env.PUBLIC_KEY);
      const memberId = decoded.sub;

      const result = await db.query(
        'SELECT id, name, email, membership_tier, bookings_count FROM members WHERE id = $1',
        [memberId]
      );

      if (result.rows.length === 0) {
        throw new Error('Member not found');
      }

      const member = result.rows[0];

      return {
        type: 'response',
        content: [
          {
            type: 'text',
            text: `Welcome back, ${member.name}! You have ${member.bookings_count} upcoming bookings.`
          },
          {
            type: 'structured',
            data: {
              name: member.name,
              email: member.email,
              membershipTier: member.membership_tier,
              upcomingBookings: member.bookings_count
            }
          }
        ]
      };
    } catch (err) {
      throw new Error(`Profile retrieval failed: ${err.message}`);
    }
  }
};

// ============= MCP ENDPOINTS =============

app.get('/mcp/tools', (req, res) => {
  res.json({ tools });
});

app.post('/mcp/invoke', async (req, res) => {
  const { id, name, arguments: args } = req.body;

  try {
    // Validate tool exists
    const tool = tools.find(t => t.name === name);
    if (!tool) {
      return res.status(404).json({
        error: {
          message: `Tool '${name}' not found`,
          code: 'TOOL_NOT_FOUND'
        }
      });
    }

    // Call handler
    const handler = handlers[name];
    const result = await handler(args);

    res.json({ id, ...result });
  } catch (err) {
    res.status(400).json({
      id,
      error: {
        message: err.message,
        code: 'TOOL_ERROR'
      }
    });
  }
});

// ============= HEALTH CHECK =============

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// ============= START SERVER =============

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`MCP Server running on port ${PORT}`);
  console.log(`Tools available: ${tools.map(t => t.name).join(', ')}`);
});

This complete example demonstrates:

  • ✅ Tool registration with detailed schemas
  • ✅ Handler implementation with error handling
  • ✅ Database queries with parameterized inputs (SQL injection prevention)
  • ✅ Token validation and idempotency checks
  • ✅ Structured response formatting
  • ✅ Proper HTTP status codes
  • ✅ Production security practices

Ready to Build Your MCP Server?

Now that you understand MCP fundamentals, architecture patterns, and best practices, you're ready to build production-ready ChatGPT apps.

Try MakeAIHQ's AI Generator to scaffold an MCP server in minutes with:

  • Pre-configured tool handlers
  • Security best practices built-in
  • Deployment templates for all platforms
  • Automatic OpenAI compliance validation

Start Free Trial →

Related Articles - Core MCP Development

Protocol & Architecture

  • Understanding window.openai: The Complete API Reference
  • MCP Protocol Fundamentals: A Developer's Guide
  • Setting Up MCP Development Environment (Node.js + Python)
  • Streamable HTTP vs SSE: Transport Protocol Comparison
  • Tool Registration & Metadata Best Practices
  • Structured Content Design Patterns for MCP Servers

Error Handling & Validation

  • Error Handling in MCP Servers: Best Practices
  • Input Validation and Type Checking in MCP Tools
  • Handling Edge Cases and Error Scenarios
  • Rate Limiting and Quota Management for MCP Servers

Performance & Optimization

  • Performance Optimization for MCP Servers
  • Caching Strategies for MCP Server Performance
  • Database Query Optimization for MCP Handlers
  • Payload Size Optimization (Sub-4k Token Responses)
  • Benchmarking and Performance Testing

Security & Authentication

  • Security Best Practices for MCP Servers
  • OAuth 2.1 Implementation for ChatGPT Apps: Step-by-Step Tutorial
  • Token Validation and Access Control
  • Protecting Secrets in MCP Servers
  • CORS Configuration and Security Headers

Testing & Debugging

  • Testing ChatGPT Apps Locally with MCP Inspector
  • Unit Testing MCP Server Tools
  • Integration Testing with MCP Inspector
  • End-to-End Testing for ChatGPT Apps
  • Debugging MCP Servers: Common Issues and Solutions

Advanced Patterns & Deployment

  • Multi-Tool Composition Strategies for Complex Workflows
  • Handling Idempotency in MCP Tools
  • Multi-Tenant Architecture for SaaS ChatGPT Apps
  • Building Stateful ChatGPT Apps with Server-Side Sessions
  • CI/CD Pipelines for MCP Server Deployment
  • Logging and Monitoring for MCP Servers
  • Versioning MCP Server APIs

Data Integration

  • Database Integration Best Practices (Firebase, PostgreSQL, MongoDB)
  • Real-Time Updates with Firebase in ChatGPT Apps
  • Webhook Implementation for Real-Time Updates
  • Third-Party API Integration Patterns
  • WebSocket Integration for Live Data Feeds

Widget Development & UX

  • Implementing Stripe Checkout in ChatGPT Widgets
  • Handling File Uploads in ChatGPT App Widgets
  • Building Rich Interactive Widgets
  • Widget State Management and Updates

Approval & Best Practices

  • 5 Common OpenAI Approval Mistakes (And How to Avoid Them)
  • OpenAI Approval Checklist and Requirements
  • Tool Discoverability: Getting ChatGPT to Use Your Tools

Foundation & Industry Guides


External Resources


Published: December 25, 2025 Updated: December 25, 2025 Author: MakeAIHQ Content Team Status: ✅ Production Ready