Skip to content

Instantly share code, notes, and snippets.

@hwillson
Created October 28, 2025 02:48
Show Gist options
  • Select an option

  • Save hwillson/9701c7df7fd54e02b82118ce24389d47 to your computer and use it in GitHub Desktop.

Select an option

Save hwillson/9701c7df7fd54e02b82118ce24389d47 to your computer and use it in GitHub Desktop.
Using Apollo MCP Server with the OpenAI Apps SDK

Using Apollo MCP Server with OpenAI Apps SDK

Overview

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.


Quick Start

1. Enable Apps SDK Support

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"

2. Create a Template

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>

3. Test in ChatGPT

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.yaml

Configuration Reference

Apps SDK Configuration

apps_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"

Template Configuration

Each template requires:

  • uri (required): Unique identifier for the template (e.g., ui://widget/my-component.html)
  • path (required): File path to the HTML template
  • meta (optional): Additional metadata for the component

Template Metadata Options

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: true

Creating Custom Templates

Understanding the Template Environment

Your 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

Basic Template Structure

<!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>

Data Structure

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)
  ]
}

Example: Launch Timeline Component

Configuration

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"

GraphQL Operation

# operations/GetUpcomingLaunches.graphql
query GetUpcomingLaunches($limit: Int = 5) {
  launchesPast(limit: $limit) {
    mission_name
    launch_date_utc
    launch_success
    rocket {
      rocket_name
    }
  }
}

Template

<!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>

User Experience

When a user asks ChatGPT "Show me recent SpaceX launches", they'll see:

  1. ChatGPT calls the GetUpcomingLaunches tool
  2. Apollo MCP Server executes the GraphQL query
  3. The response renders in a beautiful timeline component
  4. Users can interact naturally: "Show me more" or "Filter only successful launches"

Example: Data Table Component

Perfect for queries that return lists of structured data.

Configuration

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"

Template

<!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>

Per-Operation Templates

You can assign different templates to different operations for specialized visualizations.

Configuration

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!


Template Development Tips

1. Keep It Self-Contained

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">

2. Handle Empty States

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;
}

3. Handle Errors Gracefully

const { data, errors } = window.structuredContent || {};

if (errors && errors.length > 0) {
    showErrors(errors);
    return;
}

4. Use Semantic HTML

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>

5. Test Locally First

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;

Security Considerations

Content Security Policy

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"

Domain Isolation

For enhanced security, enable subdomain isolation:

meta:
  openai/widgetDomain: true

This runs your component in an isolated subdomain, preventing access to ChatGPT cookies and storage.

Best Practices

  • ✅ 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

Troubleshooting

Template Not Loading

Problem: Component doesn't render in ChatGPT

Solutions:

  1. Verify the template path is correct in your config
  2. Check file permissions - ensure the server can read the HTML file
  3. Look for syntax errors in your HTML
  4. Verify apps_sdk.enabled: true in your config

Data Not Appearing

Problem: window.structuredContent is empty or undefined

Solutions:

  1. Check your GraphQL query returns data
  2. Verify the operation completed successfully (check for errors)
  3. Ensure you're accessing the correct data path
  4. Test with the introspection tools first

Styling Issues

Problem: Component doesn't look right in ChatGPT

Solutions:

  1. Remove any CSS that assumes a specific viewport size
  2. Use relative units (%, em, rem) instead of fixed pixels
  3. Test with openai/widgetPrefersBorder: true for proper padding
  4. Avoid fixed positioning or absolute layouts

CSP Violations

Problem: Console errors about blocked resources

Solutions:

  1. Add required domains to connect_domains or resource_domains
  2. Inline all assets instead of loading externally
  3. Check that image URLs are from allowed domains

Complete Example: E-commerce Product Catalog

Here's a full example showing everything together:

Directory Structure

my-apollo-server/
├── config.yaml
├── schema.graphql
├── operations/
│   └── SearchProducts.graphql
└── templates/
    └── product-grid.html

config.yaml

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: true

operations/SearchProducts.graphql

query SearchProducts($query: String!, $limit: Int = 12) {
  products(search: $query, limit: $limit) {
    id
    name
    price
    image
    rating
    inStock
  }
}

templates/product-grid.html

<!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>

Usage in ChatGPT

User: "Show me wireless headphones under $100"

ChatGPT:

  • Calls SearchProducts with { 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"

Next Steps

  1. Start simple - Begin with the basic graphql-result template
  2. Customize gradually - Add styling and interactivity as needed
  3. Test thoroughly - Verify templates work with various data shapes
  4. Share templates - Consider creating reusable templates for common patterns
  5. Monitor usage - Use telemetry to see which operations are most used

For more information, see:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment