Apollo MCP Server integrates seamlessly with the OpenAI Apps SDK, allowing you to expose GraphQL operations in ChatGPT with custom visual components. This guide shows you how to configure your server to work with ChatGPT's component system.
Add the apps_sdk section to your configuration file:
# config.yaml
endpoint: https://api.spacex.land/graphql
operations:
source: local
paths:
- ./operations
schema:
source: local
path: ./schema.graphql
# Enable Apps SDK integration
apps_sdk:
enabled: true
templates:
- uri: "ui://widget/graphql-result.html"
path: ./templates/graphql-result.html
default_template: "ui://widget/graphql-result.html"Create a simple HTML template at ./templates/graphql-result.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 16px;
margin: 0;
}
.result {
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
}
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
</head>
<body>
<div class="result">
<pre id="output"></pre>
</div>
<script>
const data = window.structuredContent || {};
document.getElementById('output').textContent =
JSON.stringify(data, null, 2);
</script>
</body>
</html>Start your Apollo MCP Server and connect it to ChatGPT. Your GraphQL operations will now render with your custom component!
apollo-mcp-server --config config.yamlapps_sdk:
# Enable or disable Apps SDK features
enabled: true
# List of available component templates
templates:
- uri: "ui://widget/graphql-result.html"
path: ./templates/graphql-result.html
meta:
openai/widgetPrefersBorder: true
openai/widgetCSP:
connect_domains: ["api.example.com"]
# Default template for all operations
default_template: "ui://widget/graphql-result.html"
# Override templates for specific operations
operation_templates:
GetLaunches: "ui://widget/launch-timeline.html"
GetUsers: "ui://widget/user-table.html"
SearchProducts: "ui://widget/product-grid.html"Each template requires:
uri(required): Unique identifier for the template (e.g.,ui://widget/my-component.html)path(required): File path to the HTML templatemeta(optional): Additional metadata for the component
templates:
- uri: "ui://widget/example.html"
path: ./templates/example.html
meta:
# Show the component with a rounded border
openai/widgetPrefersBorder: true
# Content Security Policy settings
openai/widgetCSP:
# Domains the component can connect to
connect_domains:
- "api.example.com"
- "cdn.example.com"
# Domains for loading external resources
resource_domains:
- "images.example.com"
# Enable subdomain isolation for enhanced security
openai/widgetDomain: trueYour HTML template runs in ChatGPT with access to:
window.structuredContent- The GraphQL response data- Standard JavaScript APIs - Full ES6+ support
- CSS styling - Scoped to your component
- No external frameworks - Keep templates self-contained
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My Component</title>
<style>
/* Your styles here */
body {
font-family: system-ui, -apple-system, sans-serif;
padding: 16px;
margin: 0;
}
</style>
</head>
<body>
<div id="app"></div>
<script>
// Access GraphQL data
const { data, errors } = window.structuredContent || {};
// Render your component
function render() {
const app = document.getElementById('app');
if (errors && errors.length > 0) {
app.innerHTML = `
<div class="error">
${errors.map(e => `<p>${e.message}</p>`).join('')}
</div>
`;
return;
}
// Render your data...
app.innerHTML = `<pre>${JSON.stringify(data, null, 2)}</pre>`;
}
window.addEventListener('DOMContentLoaded', render);
</script>
</body>
</html>Your template receives GraphQL responses in this format:
window.structuredContent = {
data: {
// Your GraphQL data
launches: [
{ id: "1", mission: "Starlink-1", date: "2024-01-15" },
{ id: "2", mission: "Crew-8", date: "2024-02-20" }
]
},
errors: [
// GraphQL errors (if any)
]
}apps_sdk:
enabled: true
templates:
- uri: "ui://widget/launch-timeline.html"
path: ./templates/launch-timeline.html
meta:
openai/widgetPrefersBorder: true
openai/widgetCSP:
connect_domains: ["api.spacex.land"]
operation_templates:
GetUpcomingLaunches: "ui://widget/launch-timeline.html"# operations/GetUpcomingLaunches.graphql
query GetUpcomingLaunches($limit: Int = 5) {
launchesPast(limit: $limit) {
mission_name
launch_date_utc
launch_success
rocket {
rocket_name
}
}
}<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 16px;
margin: 0;
}
.timeline {
position: relative;
padding-left: 30px;
}
.launch {
position: relative;
padding: 12px 0;
border-left: 2px solid #e0e0e0;
padding-left: 20px;
margin-bottom: 8px;
}
.launch::before {
content: '';
position: absolute;
left: -6px;
top: 20px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #4CAF50;
}
.launch.failed::before {
background: #f44336;
}
.mission-name {
font-weight: 600;
font-size: 16px;
margin-bottom: 4px;
}
.details {
font-size: 14px;
color: #666;
}
.rocket {
display: inline-block;
background: #e3f2fd;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
margin-top: 4px;
}
</style>
</head>
<body>
<div class="timeline" id="timeline"></div>
<script>
const { data } = window.structuredContent || {};
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function render() {
const timeline = document.getElementById('timeline');
if (!data || !data.launchesPast) {
timeline.innerHTML = '<p>No launch data available</p>';
return;
}
timeline.innerHTML = data.launchesPast.map(launch => `
<div class="launch ${launch.launch_success ? '' : 'failed'}">
<div class="mission-name">${launch.mission_name}</div>
<div class="details">
${formatDate(launch.launch_date_utc)}
</div>
<div class="rocket">${launch.rocket.rocket_name}</div>
</div>
`).join('');
}
window.addEventListener('DOMContentLoaded', render);
</script>
</body>
</html>When a user asks ChatGPT "Show me recent SpaceX launches", they'll see:
- ChatGPT calls the
GetUpcomingLaunchestool - Apollo MCP Server executes the GraphQL query
- The response renders in a beautiful timeline component
- Users can interact naturally: "Show me more" or "Filter only successful launches"
Perfect for queries that return lists of structured data.
apps_sdk:
enabled: true
templates:
- uri: "ui://widget/data-table.html"
path: ./templates/data-table.html
operation_templates:
GetUsers: "ui://widget/data-table.html"
GetProducts: "ui://widget/data-table.html"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: system-ui, sans-serif;
padding: 16px;
margin: 0;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
}
th {
background: #f5f5f5;
text-align: left;
padding: 12px;
font-weight: 600;
border-bottom: 2px solid #e0e0e0;
}
td {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
}
tr:hover {
background: #fafafa;
}
.empty {
text-align: center;
padding: 32px;
color: #999;
}
</style>
</head>
<body>
<div id="table-container"></div>
<script>
function renderTable(data) {
const container = document.getElementById('table-container');
if (!data || Object.keys(data).length === 0) {
container.innerHTML = '<div class="empty">No data available</div>';
return;
}
// Get the first array from the data object
const arrayKey = Object.keys(data).find(k => Array.isArray(data[k]));
if (!arrayKey || data[arrayKey].length === 0) {
container.innerHTML = '<div class="empty">No results found</div>';
return;
}
const items = data[arrayKey];
const headers = Object.keys(items[0]);
container.innerHTML = `
<table>
<thead>
<tr>
${headers.map(h => `<th>${h}</th>`).join('')}
</tr>
</thead>
<tbody>
${items.map(item => `
<tr>
${headers.map(h => `
<td>${formatValue(item[h])}</td>
`).join('')}
</tr>
`).join('')}
</tbody>
</table>
`;
}
function formatValue(value) {
if (value === null || value === undefined) return '-';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
const { data } = window.structuredContent || {};
window.addEventListener('DOMContentLoaded', () => renderTable(data));
</script>
</body>
</html>You can assign different templates to different operations for specialized visualizations.
apps_sdk:
enabled: true
templates:
# Generic table view
- uri: "ui://widget/table.html"
path: ./templates/table.html
# Timeline for events
- uri: "ui://widget/timeline.html"
path: ./templates/timeline.html
# Card grid for media
- uri: "ui://widget/card-grid.html"
path: ./templates/card-grid.html
# Map view for locations
- uri: "ui://widget/map.html"
path: ./templates/map.html
meta:
openai/widgetCSP:
connect_domains: ["api.mapbox.com"]
# Default fallback
default_template: "ui://widget/table.html"
# Specific operation mappings
operation_templates:
GetUsers: "ui://widget/table.html"
GetLaunches: "ui://widget/timeline.html"
SearchProducts: "ui://widget/card-grid.html"
GetStoreLocations: "ui://widget/map.html"Now each operation automatically uses the most appropriate visualization!
Bundle all CSS and JavaScript inline. Avoid external dependencies.
<!-- Good -->
<style>
.my-component { color: blue; }
</style>
<!-- Avoid -->
<link rel="stylesheet" href="https://cdn.example.com/style.css">Always check for missing data:
const { data } = window.structuredContent || {};
if (!data || !data.items || data.items.length === 0) {
document.getElementById('app').innerHTML =
'<p>No results found</p>';
return;
}const { data, errors } = window.structuredContent || {};
if (errors && errors.length > 0) {
showErrors(errors);
return;
}Make your components accessible:
<table role="table" aria-label="User data">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
</tr>
</thead>
<!-- ... -->
</table>Test your template by opening it directly in a browser with mock data:
// For local testing
const mockData = {
data: {
users: [
{ name: "Alice", email: "[email protected]" },
{ name: "Bob", email: "[email protected]" }
]
}
};
window.structuredContent = window.structuredContent || mockData;The openai/widgetCSP metadata controls what your component can access:
templates:
- uri: "ui://widget/secure-component.html"
path: ./templates/secure.html
meta:
openai/widgetCSP:
# APIs your component needs to call
connect_domains:
- "api.example.com"
- "analytics.example.com"
# External resources (images, fonts, etc.)
resource_domains:
- "cdn.example.com"For enhanced security, enable subdomain isolation:
meta:
openai/widgetDomain: trueThis runs your component in an isolated subdomain, preventing access to ChatGPT cookies and storage.
- ✅ Only request domains you actually need
- ✅ Validate and sanitize data before rendering
- ✅ Avoid inline event handlers (
onclick, etc.) - ✅ Use textContent instead of innerHTML when possible
- ❌ Don't include authentication tokens in templates
- ❌ Don't make requests to untrusted domains
Problem: Component doesn't render in ChatGPT
Solutions:
- Verify the template path is correct in your config
- Check file permissions - ensure the server can read the HTML file
- Look for syntax errors in your HTML
- Verify
apps_sdk.enabled: truein your config
Problem: window.structuredContent is empty or undefined
Solutions:
- Check your GraphQL query returns data
- Verify the operation completed successfully (check for errors)
- Ensure you're accessing the correct data path
- Test with the introspection tools first
Problem: Component doesn't look right in ChatGPT
Solutions:
- Remove any CSS that assumes a specific viewport size
- Use relative units (%, em, rem) instead of fixed pixels
- Test with
openai/widgetPrefersBorder: truefor proper padding - Avoid fixed positioning or absolute layouts
Problem: Console errors about blocked resources
Solutions:
- Add required domains to
connect_domainsorresource_domains - Inline all assets instead of loading externally
- Check that image URLs are from allowed domains
Here's a full example showing everything together:
my-apollo-server/
├── config.yaml
├── schema.graphql
├── operations/
│ └── SearchProducts.graphql
└── templates/
└── product-grid.html
endpoint: https://api.mystore.com/graphql
transport:
type: streamable_http
address: "127.0.0.1"
port: 3000
operations:
source: local
paths:
- ./operations
schema:
source: local
path: ./schema.graphql
apps_sdk:
enabled: true
templates:
- uri: "ui://widget/product-grid.html"
path: ./templates/product-grid.html
meta:
openai/widgetPrefersBorder: true
openai/widgetCSP:
connect_domains: ["api.mystore.com"]
resource_domains: ["images.mystore.com"]
operation_templates:
SearchProducts: "ui://widget/product-grid.html"
cors:
enabled: true
allow_any_origin: truequery SearchProducts($query: String!, $limit: Int = 12) {
products(search: $query, limit: $limit) {
id
name
price
image
rating
inStock
}
}<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 16px;
margin: 0;
background: #fafafa;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.product {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.product:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.product-image {
width: 100%;
height: 200px;
object-fit: cover;
background: #f0f0f0;
}
.product-info {
padding: 12px;
}
.product-name {
font-weight: 600;
margin: 0 0 8px 0;
font-size: 14px;
}
.product-price {
color: #1976d2;
font-size: 18px;
font-weight: 700;
margin: 4px 0;
}
.product-rating {
color: #ffa726;
font-size: 14px;
}
.stock-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-top: 8px;
}
.in-stock {
background: #e8f5e9;
color: #2e7d32;
}
.out-of-stock {
background: #ffebee;
color: #c62828;
}
.empty {
text-align: center;
padding: 48px 16px;
color: #999;
}
</style>
</head>
<body>
<div id="products"></div>
<script>
function renderProducts() {
const container = document.getElementById('products');
const { data, errors } = window.structuredContent || {};
if (errors && errors.length > 0) {
container.innerHTML = `
<div class="empty">
<p>Error loading products</p>
</div>
`;
return;
}
if (!data || !data.products || data.products.length === 0) {
container.innerHTML = `
<div class="empty">
<p>No products found</p>
</div>
`;
return;
}
container.innerHTML = `
<div class="grid">
${data.products.map(product => `
<div class="product">
<img
src="${product.image || 'https://via.placeholder.com/200'}"
alt="${product.name}"
class="product-image"
>
<div class="product-info">
<h3 class="product-name">${product.name}</h3>
<div class="product-price">
$${product.price.toFixed(2)}
</div>
<div class="product-rating">
${'★'.repeat(Math.floor(product.rating))}${'☆'.repeat(5 - Math.floor(product.rating))}
${product.rating.toFixed(1)}
</div>
<span class="stock-badge ${product.inStock ? 'in-stock' : 'out-of-stock'}">
${product.inStock ? 'In Stock' : 'Out of Stock'}
</span>
</div>
</div>
`).join('')}
</div>
`;
}
window.addEventListener('DOMContentLoaded', renderProducts);
</script>
</body>
</html>User: "Show me wireless headphones under $100"
ChatGPT:
- Calls
SearchProductswith{ query: "wireless headphones", limit: 12 } - Renders beautiful product grid with images, prices, ratings
- User can naturally refine: "Show only in-stock items" or "Sort by rating"
- Start simple - Begin with the basic graphql-result template
- Customize gradually - Add styling and interactivity as needed
- Test thoroughly - Verify templates work with various data shapes
- Share templates - Consider creating reusable templates for common patterns
- Monitor usage - Use telemetry to see which operations are most used
For more information, see: