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.
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 │
└─────────────────────────────────────────────────────────────────┘
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
- Mapbox account with access token (get one here). For additional info on tokens see the deep dive.
- Node.js 18+ installed
- ChatGPT Plus or Team account with developer mode enabled
- (Optional): Download ngrok for local development. ChatGPT requires the MCP be hosted behind an HTTPS endpoint, ngrok is a simple way to do this.
1. Clone the repository
git clone https://github.com/mapbox/location-ai-tutorials.git
cd location-ai-tutorials/chatgpt-app-tutorial
2. Install dependencies
npm install
| 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
Edit .env and add your token:
MAPBOX_ACCESS_TOKEN=pk.your_token_here
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)
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;
}
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
}
}]
})
);
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
}
);
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();
}
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
}
}))
};
}
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
};
}
}
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`);
});
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
});
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>
Testing
1. Start the server
npm start
You should see:
POI Category Search MCP Server
==============================
Server running at: http://localhost:8787
MCP endpoint: http://localhost:8787/mcp
2. Test with MCP Inspector
npx @modelcontextprotocol/inspector@latest http://localhost:8787/mcp
You should see the tool find_nearby_places and the resource places-widget in the 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
Copy the https:// URL.
4. Add to ChatGPT and Test Your App
- Go to ChatGPT → Settings → Apps & Connectors → Advanced settings
- Enable developer mode
- Add a connector with your ngrok URL +
/mcp(e.g.,https://abc123.ngrok.io/mcp) - Open a new chat, click the connector icon, add your connector
- Try: "Find coffee shops near Times Square"
You've now successfully built and integrated your first ChatGPT App!
Additional Resources
Mapbox
- Mapbox Location AI Tutorials repository
- Mapbox MCP Server
- Mapbox Store Locator Tutorial - The original tutorial this builds upon
- Mapbox GL JS Documentation - Full guide to the mapping library
- Mapbox Geocoding API - Convert addresses to coordinates
- Mapbox Search Box API - Search for places by category
- Mapbox Access Tokens - Get your API token
OpenAI / ChatGPT Apps
- ChatGPT Apps SDK - Complete documentation for building ChatGPT Apps
- MCP Inspector - Test your MCP server locally
- Connecting Your App to ChatGPT




Top comments (0)