DEV Community

Cover image for Build a ChatGPT App with Mapbox
Chris Tufts for Mapbox

Posted on

Build a ChatGPT App with Mapbox

ChatGPT Apps are a new way to build interactive experiences within the ChatGPT interface. In this tutorial, we'll explore and run a Points of Interest (POI) search app powered by Mapbox APIs that lets users find places like coffee shops, restaurants, and gas stations near any location using natural language. The focus of this tutorial is on understanding how to build a ChatGPT App using the ChatGPT Apps SDK and the Model Context Protocol (MCP). The app is loosely based on the Mapbox Build a store locator using Mapbox GL JS tutorial, but adapted for the ChatGPT UI. For the remainder of the post I'll refer to ChatGPT apps as apps for brevity.

How ChatGPT Apps Work

From a user perspective, utilizing an app is identical to the traditional chat workflow: select any tools you want to use, type a prompt into the chat interface, and you get a response.

ChatGPT Mapbox App UI Flow

The workflow under the hood starts in ChatGPT, utilizes your app (an MCP server), and uses the results to render a widget in the chat UI. The process is shown in the following diagram.

┌─────────────────────────────────────────────────────────────────┐
│                         ChatGPT                                 │
│        User: "Find coffee shops near Times Square"              |
│                           │                                     │
│                           ▼                                     │
│              Calls find_nearby_places tool                      │
└───────────────────────────┬─────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Your MCP Server                              │
│  • Geocode "Times Square" → coordinates (Geocoding API)         │
│  • Search for "coffee" near coordinates (Search Box API)        │
│  • Return structuredContent with place data                     │
└───────────────────────────┬─────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│                         ChatGPT                                 │
│  • Receives structuredContent from tool response                │
│  • Sets window.openai.toolOutput = structuredContent            │
│  • Loads your widget HTML (from MCP resource) in an iframe      │
└───────────────────────────┬─────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│              Your Widget (iframe in ChatGPT UI)                 │
│  • Your HTML/CSS/JS from widget.html                            │
│  • Reads data from window.openai.toolOutput                     │
│  • Renders Mapbox GL JS map with markers                        │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Concepts

If you are new to MCP's and ChatGPT apps, the following table shows some new key concepts to keep an eye out for as we go through the example. The table is meant for quick reference as more detail will be provided as we progress through the tutorial.

Concept What It Is In Our App
MCP Model Context Protocol— the spec for connecting an LLM with external tools and resources The App is an MCP server
Resource An HTML template ChatGPT can render Our map widget (widget.html)
Tool External tools our MCP exposes to ChatGPT find_nearby_places tool
text/html+skybridge MIME type that tells ChatGPT "this is a widget" Set on our resource
window.openai.toolOutput How tool results reach your widget Contains our POI/place data

Learn more: See the ChatGPT Apps SDK documentation for detailed guides on MCP servers, widgets, tools, and more.

Project Setup

Prerequisites

1. Clone the repository

git clone https://github.com/mapbox/location-ai-tutorials.git
cd location-ai-tutorials/chatgpt-app-tutorial
Enter fullscreen mode Exit fullscreen mode

2. Install dependencies

npm install
Enter fullscreen mode Exit fullscreen mode
Package Purpose
@modelcontextprotocol/sdk MCP protocol implementation
zod Schema validation (required by MCP)
dotenv Load environment variables

3. Configure environment

Copy the example environment file and add your Mapbox token:

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

Edit .env and add your token:

MAPBOX_ACCESS_TOKEN=pk.your_token_here
Enter fullscreen mode Exit fullscreen mode

4. Project structure

chatgpt-app-tutorial/
├── server.js           # MCP server
├── public/
│   └── widget.html     # Map widget
├── package.json
├── .env.example
└── .env                # Your local config (not tracked)
Enter fullscreen mode Exit fullscreen mode

Understanding the MCP Server

Let's walk through server.js to understand how the MCP server works.

Server setup

import "dotenv/config";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { z } from "zod";

// Load widget HTML and inject Mapbox token
const widgetHtml = readFileSync("public/widget.html", "utf8")
  .replace("{{MAPBOX_ACCESS_TOKEN}}", process.env.MAPBOX_ACCESS_TOKEN || "");

function createPoiSearchServer() {
  const server = new McpServer({
    name: "poi-search",
    version: "1.0.0"
  });

  // Resources are added here

  return server;
}
Enter fullscreen mode Exit fullscreen mode

Register the widget resource

Resources are HTML templates that ChatGPT can render. The key is the text/html+skybridge MIME type which tells ChatGPT it's a widget, not just HTML text.

server.registerResource(
  "places-widget",           // Name (for reference)
  "ui://widget/places.html", // URI (links tool output to this widget)
  {},
  async () => ({
    contents: [{
      uri: "ui://widget/places.html",
      mimeType: "text/html+skybridge",  // This makes it a widget!
      text: widgetHtml,
      _meta: {
        "openai/widgetPrefersBorder": true
      }
    }]
  })
);
Enter fullscreen mode Exit fullscreen mode

Register the find_nearby_places tool

Tools are functions ChatGPT can call. The _meta["openai/outputTemplate"] links this tool to our widget. When ChatGPT calls the tool, the widget renders with the returned data.

server.registerTool(
  "find_nearby_places",
  {
    title: "Find Nearby Places",
    description: "Use this when the user wants to find places like coffee shops, restaurants, gas stations, grocery stores, or other businesses near a specific location. Always requires both a type of place and a location.",
    inputSchema: {
      category: z.string().describe("Type of place to search for (e.g., 'coffee', 'restaurant', 'grocery', 'pharmacy', 'gas station', 'hotel', 'bank')"),
      location: z.string().describe("Location to search near (e.g., 'Times Square NYC', 'downtown Seattle', '1600 Pennsylvania Ave')")
    },
    _meta: {
      "openai/outputTemplate": "ui://widget/places.html",  // Link to widget
      "openai/toolInvocation/invoking": "Searching for nearby places...",
      "openai/toolInvocation/invoked": "Places found"
    }
  },
  async ({ category, location }) => {
    // Implementation coming next
  }
);
Enter fullscreen mode Exit fullscreen mode

Add Mapbox API helpers

The following functions call Mapbox APIs to convert text locations to coordinates and search for places.

// Convert text location to coordinates
async function geocodeLocation(locationText, accessToken) {
  const url = new URL("https://api.mapbox.com/search/geocode/v6/forward");
  url.searchParams.set("q", locationText);
  url.searchParams.set("access_token", accessToken);
  url.searchParams.set("limit", "1");

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Geocoding failed: ${response.status}`);
  }

  const data = await response.json();
  if (!data.features || data.features.length === 0) {
    throw new Error(`Could not find location: ${locationText}. Try a more specific address or city name.`);
  }

  const feature = data.features[0];
  const [longitude, latitude] = feature.geometry.coordinates;
  const placeName = feature.properties.full_address || feature.properties.name || locationText;
  return { longitude, latitude, placeName };
}

// Search for places by category
async function searchCategory(categoryId, longitude, latitude, accessToken) {
  const url = new URL(`https://api.mapbox.com/search/searchbox/v1/category/${encodeURIComponent(categoryId)}`);
  url.searchParams.set("access_token", accessToken);
  url.searchParams.set("proximity", `${longitude},${latitude}`);
  url.searchParams.set("limit", "10");

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Category search failed: ${response.status}`);
  }

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Add category mapping and response transformer

These helper functions normalize user input to Mapbox category IDs and transform the API response to our widget's expected format:

// Category mappings - maps common user terms to Mapbox category IDs
const CATEGORY_MAPPINGS = {
  "coffee": "coffee",
  "coffee shop": "coffee",
  "restaurant": "restaurant",
  "grocery": "grocery",
  "gas station": "gas_station",
  "pharmacy": "pharmacy",
  "hotel": "hotel",
  "bank": "bank",
  // ... add more as needed
};

function mapCategoryToId(userCategory) {
  const normalized = userCategory.toLowerCase().trim();
  return CATEGORY_MAPPINGS[normalized] || normalized;
}

// Transform Mapbox response to widget-compatible GeoJSON
function transformToWidgetGeoJSON(mapboxResponse, categoryName) {
  return {
    type: "FeatureCollection",
    features: (mapboxResponse.features || []).map((feature) => ({
      type: "Feature",
      geometry: feature.geometry,
      properties: {
        name: feature.properties.name || "Unknown",
        address: feature.properties.address || feature.properties.full_address || "",
        city: feature.properties.context?.place?.name || "",
        state: feature.properties.context?.region?.region_code || "",
        postalCode: feature.properties.context?.postcode?.name || "",
        phoneFormatted: feature.properties.metadata?.phone || null,
        category: categoryName,
        mapbox_id: feature.properties.mapbox_id
      }
    }))
  };
}
Enter fullscreen mode Exit fullscreen mode

Implement the tool

Now wire up the tool to use these helper functions and return data in the format our widget expects:

async ({ category, location }) => {
  const accessToken = process.env.MAPBOX_ACCESS_TOKEN;

  if (!accessToken) {
    return {
      content: [{ type: "text", text: "Error: Mapbox access token not configured." }],
      isError: true
    };
  }

  try {
    // Step 1: Geocode the location
    const { longitude, latitude, placeName } = await geocodeLocation(location, accessToken);

    // Step 2: Map category to Mapbox ID
    const categoryId = mapCategoryToId(category);

    // Step 3: Search for places
    const searchResults = await searchCategory(categoryId, longitude, latitude, accessToken);

    // Step 4: Transform to widget format
    const places = transformToWidgetGeoJSON(searchResults, category);

    // Step 5: Calculate map center and zoom
    const center = [longitude, latitude];
    const zoom = places.features.length > 0 ? 13 : 12;

    return {
      structuredContent: {
        places: places,
        center: center,
        zoom: zoom,
        searchLocation: placeName,
        searchCategory: category
      },
      content: [{
        type: "text",
        text: places.features.length > 0
          ? `Found ${places.features.length} ${category} places near ${placeName}.`
          : `No ${category} places found near ${placeName}. Try a different location or category.`
      }]
    };
  } catch (error) {
    return {
      content: [{ type: "text", text: `Search failed: ${error.message}` }],
      isError: true
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Set up the HTTP server

ChatGPT connects to your server via HTTP. The /mcp endpoint handles the MCP protocol.

const PORT = process.env.PORT || 8787;

const httpServer = createServer(async (req, res) => {
  // CORS headers (required for ChatGPT)
  if (req.method === "OPTIONS") {
    res.writeHead(204, {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "POST, GET, OPTIONS, DELETE",
      "Access-Control-Allow-Headers": "Content-Type, mcp-session-id"
    });
    res.end();
    return;
  }

  // MCP endpoint
  if (new URL(req.url, `http://localhost`).pathname === "/mcp") {
    res.setHeader("Access-Control-Allow-Origin", "*");

    const server = createPoiSearchServer();
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,  // Stateless mode
      enableJsonResponse: true
    });

    res.on("close", () => {
      transport.close();
      server.close();
    });

    await server.connect(transport);
    await transport.handleRequest(req, res);
    return;
  }

  res.writeHead(404);
  res.end("Not Found");
});

httpServer.listen(PORT, () => {
  console.log(`MCP Server running at http://localhost:${PORT}/mcp`);
});
Enter fullscreen mode Exit fullscreen mode

Understanding the Widget

When a tool returns structuredContent, ChatGPT can render a custom UI embedded directly in the chat interface known as a widget. This is what transforms our app from plain text responses into an interactive map experience.

The widget is loaded in an iframe within ChatGPT. It receives data from the tool via window.openai.toolOutput and can render anything a web page can: maps, charts, forms, or any interactive UI. In our case, the widget (public/widget.html) displays a Mapbox map with place markers and a clickable sidebar.

Since the widget is just HTML/CSS/JavaScript, you have full control over the user experience without requiring any special framework.

Updating the Widget's Data

The data arrives via window.openai.toolOutput and it might not be available immediately when your script runs. We set up an event listener to ensure the widget can updated once the data is received from the API's.

// Traditional app
const places = await fetch('/api/places').then(r => r.json());

// ChatGPT App - data comes from tool output
const places = window.openai?.toolOutput?.places;

// But it might arrive later, so also listen for updates
window.addEventListener("openai:set_globals", (event) => {
  const places = event.detail?.globals?.toolOutput?.places;
  // Re-render with new data
});
Enter fullscreen mode Exit fullscreen mode

Widget structure

<!DOCTYPE html>
<html>
<head>
  <script src="https://api.mapbox.com/mapbox-gl-js/v3.14.0/mapbox-gl.js"></script>
  <link href="https://api.mapbox.com/mapbox-gl-js/v3.14.0/mapbox-gl.css" rel="stylesheet"/>
  <style>
    /* Sidebar + map layout */
    .container { display: flex; height: 100vh; }
    .sidebar { width: 280px; overflow-y: auto; padding: 1rem; }
    .sidebar.hidden { display: none; }
    .map { flex: 1; }
    /* Place cards, markers, etc. */
  </style>
</head>
<body>
  <div class="container">
    <!-- Sidebar starts hidden until results arrive -->
    <div class="sidebar hidden">
      <h2 id="place-count">Places nearby: 0</h2>
      <div id="listings"></div>
    </div>
    <div id="map" class="map"></div>
  </div>

  <script>
    // Get data from ChatGPT tool output
    let places = window.openai?.toolOutput?.places || { type: "FeatureCollection", features: [] };
    let mapCenter = window.openai?.toolOutput?.center || [-77.03915, 38.90025];
    let mapZoom = window.openai?.toolOutput?.zoom || 4;

    // Listen for data updates
    window.addEventListener("openai:set_globals", (event) => {
      const output = event.detail?.globals?.toolOutput;
      if (output?.places) {
        places = output.places;
        mapCenter = output.center ?? mapCenter;
        mapZoom = output.zoom ?? mapZoom;
        // Re-render everything
        clearMarkers();
        addMarkers();
        buildLocationList();
        // Show sidebar now that we have data
        document.querySelector('.sidebar').classList.remove('hidden');
        map.resize();
        map.flyTo({ center: mapCenter, zoom: mapZoom });
      }
    });

    // Standard Mapbox GL JS code
    mapboxgl.accessToken = '{{MAPBOX_ACCESS_TOKEN}}';
    const map = new mapboxgl.Map({
      container: 'map',
      center: mapCenter,
      zoom: mapZoom,
      config: {
        basemap: { theme: 'faded' }
      }
    });

    // Add markers, build listings, handle clicks...
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Testing

1. Start the server

npm start
Enter fullscreen mode Exit fullscreen mode

You should see:

POI Category Search MCP Server
==============================
Server running at: http://localhost:8787
MCP endpoint:      http://localhost:8787/mcp
Enter fullscreen mode Exit fullscreen mode

2. Test with MCP Inspector

npx @modelcontextprotocol/inspector@latest http://localhost:8787/mcp
Enter fullscreen mode Exit fullscreen mode

You should see the tool find_nearby_places and the resource places-widget in the inspector.

MCP Resources in MCP Inspector

MCP Tools in MCP Inspector

This lets you call tools and see responses without ChatGPT.

3. Expose with ngrok

ChatGPT needs a public URL to connect:

ngrok http 8787
Enter fullscreen mode Exit fullscreen mode

Copy the https:// URL.

4. Add to ChatGPT and Test Your App

  1. Go to ChatGPT → Settings → Apps & Connectors → Advanced settings
  2. Enable developer mode
  3. Add a connector with your ngrok URL + /mcp (e.g., https://abc123.ngrok.io/mcp)
  4. Open a new chat, click the connector icon, add your connector
  5. Try: "Find coffee shops near Times Square"

You've now successfully built and integrated your first ChatGPT App!

App & Response for

Additional Resources

Mapbox

OpenAI / ChatGPT Apps

Top comments (0)