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
- MCP Protocol Fundamentals
- Architecture Patterns for Scalable Servers
- Implementing Tool Handlers
- Transport Layers: Streamable HTTP vs SSE
- Structured Content and Widget Integration
- Error Handling and Validation
- Testing with MCP Inspector
- Performance Optimization
- Security Best Practices
- Deployment Strategies
- 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
EventSourceAPI - 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:
- View all registered tools
- Test tool calls with sample inputs
- Inspect request/response payloads
- 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:
- User: "Book me a yoga class tomorrow"
- ChatGPT: "Which instructor?"
- User: "Sarah Chen"
- ChatGPT calls: searchClasses({instructor: 'Sarah Chen'})
- User selects class via widget
- ChatGPT calls: bookClass({classId: 'class-123'})
- Confirmation: "Booking confirmed! Confirmation #CONF-2025-123"
- User never leaves ChatGPT
❌ Bad Example:
- User: "Book me a yoga class"
- ChatGPT: "Click here to book" [links to external website]
- 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:
- Specific descriptions: Describe exactly what tool does, not just the function name
- Usage guidance: Explain when ChatGPT should use this tool vs others
- Example parameters: Show what good input looks like
- Return value documentation: Describe the structured data returned
- 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
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
- Complete Guide to Building ChatGPT Applications
- ChatGPT Apps for Fitness Studios: Complete Guide
- ChatGPT Apps for Restaurants: Complete Guide
External Resources
- Official MCP Specification
- OpenAI Apps SDK Documentation
- OpenAI Apps SDK Examples on GitHub
- MCP Inspector Tool
- JSON Schema Specification
Published: December 25, 2025 Updated: December 25, 2025 Author: MakeAIHQ Content Team Status: ✅ Production Ready