Skip to main content
Glama

OP.GG MCP Server

Official
by opgginc
apps-sdk_build_examples.txt52.3 kB
--- url: "https://developers.openai.com/apps-sdk/build/examples" title: "Examples" --- ## Search the docs ⌘K/CtrlK Close Clear Primary navigation ChatGPT ResourcesCodexChatGPTBlog Clear - [Home](https://developers.openai.com/apps-sdk) ### Core Concepts - [MCP Server](https://developers.openai.com/apps-sdk/concepts/mcp-server) - [User interaction](https://developers.openai.com/apps-sdk/concepts/user-interaction) - [Design guidelines](https://developers.openai.com/apps-sdk/concepts/design-guidelines) ### Plan - [Research use cases](https://developers.openai.com/apps-sdk/plan/use-case) - [Define tools](https://developers.openai.com/apps-sdk/plan/tools) - [Design components](https://developers.openai.com/apps-sdk/plan/components) ### Build - [Set up your server](https://developers.openai.com/apps-sdk/build/mcp-server) - [Build a custom UX](https://developers.openai.com/apps-sdk/build/custom-ux) - [Authenticate users](https://developers.openai.com/apps-sdk/build/auth) - [Persist state](https://developers.openai.com/apps-sdk/build/storage) - [Examples](https://developers.openai.com/apps-sdk/build/examples) ### Deploy - [Deploy your app](https://developers.openai.com/apps-sdk/deploy) - [Connect from ChatGPT](https://developers.openai.com/apps-sdk/deploy/connect-chatgpt) - [Test your integration](https://developers.openai.com/apps-sdk/deploy/testing) ### Guides - [Optimize Metadata](https://developers.openai.com/apps-sdk/guides/optimize-metadata) - [Security & Privacy](https://developers.openai.com/apps-sdk/guides/security-privacy) - [Troubleshooting](https://developers.openai.com/apps-sdk/deploy/troubleshooting) ### Resources - [Reference](https://developers.openai.com/apps-sdk/reference) - [App developer guidelines](https://developers.openai.com/apps-sdk/app-developer-guidelines) ## Overview The Pizzaz demo app bundles a handful of UI components so you can see the full tool surface area end-to-end. The following sections walk through the MCP server and the component implementations that power those tools. You can find the “Pizzaz” demo app and other examples in our [examples repository on GitHub](https://github.com/openai/openai-apps-sdk-examples). Use these examples as blueprints when you assemble your own app. ## MCP Source This TypeScript server shows how to register multiple tools that share data with pre-built UI resources. Each resource call returns a Skybridge HTML shell, and every tool responds with matching metadata so ChatGPT knows which component to render. ``` import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; declare const server: McpServer; // UI resource (no inline data assignment; host will inject data) server.registerResource( "pizza-map", "ui://widget/pizza-map.html", {}, async () => ({ contents: [\ {\ uri: "ui://widget/pizza-map.html",\ mimeType: "text/html+skybridge",\ text: `\ <div id="pizzaz-root"></div>\ <link rel="stylesheet" href="https://persistent.oaistatic.com/ecosystem-built-assets/pizzaz-0038.css">\ <script type="module" src="https://persistent.oaistatic.com/ecosystem-built-assets/pizzaz-0038.js"></script>\ `.trim(),\ },\ ], }) ); server.registerTool( "pizza-map", { title: "Show Pizza Map", _meta: { "openai/outputTemplate": "ui://widget/pizza-map.html", "openai/toolInvocation/invoking": "Hand-tossing a map", "openai/toolInvocation/invoked": "Served a fresh map", }, inputSchema: { pizzaTopping: z.string() }, }, async () => { return { content: [{ type: "text", text: "Rendered a pizza map!" }], structuredContent: {}, }; } ); server.registerResource( "pizza-carousel", "ui://widget/pizza-carousel.html", {}, async () => ({ contents: [\ {\ uri: "ui://widget/pizzaz-carousel.html",\ mimeType: "text/html+skybridge",\ text: `\ <div id="pizzaz-carousel-root"></div>\ <link rel="stylesheet" href="https://persistent.oaistatic.com/ecosystem-built-assets/pizzaz-carousel-0038.css">\ <script type="module" src="https://persistent.oaistatic.com/ecosystem-built-assets/pizzaz-carousel-0038.js"></script>\ `.trim(),\ },\ ], }) ); server.registerTool( "pizza-carousel", { title: "Show Pizza Carousel", _meta: { "openai/outputTemplate": "ui://widget/pizza-carousel.html", "openai/toolInvocation/invoking": "Carousel some spots", "openai/toolInvocation/invoked": "Served a fresh carousel", }, inputSchema: { pizzaTopping: z.string() }, }, async () => { return { content: [{ type: "text", text: "Rendered a pizza carousel!" }], structuredContent: {}, }; } ); server.registerResource( "pizza-albums", "ui://widget/pizza-albums.html", {}, async () => ({ contents: [\ {\ uri: "ui://widget/pizzaz-albums.html",\ mimeType: "text/html+skybridge",\ text: `\ <div id="pizzaz-albums-root"></div>\ <link rel="stylesheet" href="https://persistent.oaistatic.com/ecosystem-built-assets/pizzaz-albums-0038.css">\ <script type="module" src="https://persistent.oaistatic.com/ecosystem-built-assets/pizzaz-albums-0038.js"></script>\ `.trim(),\ },\ ], }) ); server.registerTool( "pizza-albums", { title: "Show Pizza Album", _meta: { "openai/outputTemplate": "ui://widget/pizza-albums.html", "openai/toolInvocation/invoking": "Hand-tossing an album", "openai/toolInvocation/invoked": "Served a fresh album", }, inputSchema: { pizzaTopping: z.string() }, }, async () => { return { content: [{ type: "text", text: "Rendered a pizza album!" }], structuredContent: {}, }; } ); server.registerResource( "pizza-list", "ui://widget/pizza-list.html", {}, async () => ({ contents: [\ {\ uri: "ui://widget/pizzaz-list.html",\ mimeType: "text/html+skybridge",\ text: `\ <div id="pizzaz-list-root"></div>\ <link rel="stylesheet" href="https://persistent.oaistatic.com/ecosystem-built-assets/pizzaz-list-0038.css">\ <script type="module" src="https://persistent.oaistatic.com/ecosystem-built-assets/pizzaz-list-0038.js"></script>\ `.trim(),\ },\ ], }) ); server.registerTool( "pizza-list", { title: "Show Pizza List", _meta: { "openai/outputTemplate": "ui://widget/pizza-list.html", "openai/toolInvocation/invoking": "Hand-tossing a list", "openai/toolInvocation/invoked": "Served a fresh list", }, inputSchema: { pizzaTopping: z.string() }, }, async () => { return { content: [{ type: "text", text: "Rendered a pizza list!" }], structuredContent: {}, }; } ); server.registerResource( "pizza-video", "ui://widget/pizza-video.html", {}, async () => ({ contents: [\ {\ uri: "ui://widget/pizzaz-video.html",\ mimeType: "text/html+skybridge",\ text: `\ <div id="pizzaz-video-root"></div>\ <link rel="stylesheet" href="https://persistent.oaistatic.com/ecosystem-built-assets/pizzaz-video-0038.css">\ <script type="module" src="https://persistent.oaistatic.com/ecosystem-built-assets/pizzaz-video-0038.js"></script>\ `.trim(),\ },\ ], }) ); server.registerTool( "pizza-video", { title: "Show Pizza Video", _meta: { "openai/outputTemplate": "ui://widget/pizza-video.html", "openai/toolInvocation/invoking": "Hand-tossing a video", "openai/toolInvocation/invoked": "Served a fresh video", }, inputSchema: { pizzaTopping: z.string() }, }, async () => { return { content: [{ type: "text", text: "Rendered a pizza video!" }], structuredContent: {}, }; } ); ``` ## Pizzaz Map Source ![Screenshot of the Pizzaz map component](https://developers.openai.com/images/apps-sdk/pizzaz-map.png) The map component is a React + Mapbox client that syncs its state back to ChatGPT. It renders marker interactions, inspector routing, and fullscreen handling so you can study a heavier, stateful component example. ``` import React, { useEffect, useRef, useState } from "react"; import mapboxgl from "mapbox-gl"; import "mapbox-gl/dist/mapbox-gl.css"; import { createRoot } from "react-dom/client"; import markers from "./markers.json"; import { AnimatePresence } from "framer-motion"; import Inspector from "./Inspector"; import Sidebar from "./Sidebar"; import { useOpenaiGlobal } from "../use-openai-global"; import { useMaxHeight } from "../use-max-height"; import { Maximize2 } from "lucide-react"; import { useNavigate, useLocation, Routes, Route, BrowserRouter, Outlet, } from "react-router-dom"; mapboxgl.accessToken = "pk.eyJ1IjoiZXJpY25pbmciLCJhIjoiY21icXlubWM1MDRiczJvb2xwM2p0amNyayJ9.n-3O6JI5nOp_Lw96ZO5vJQ"; function fitMapToMarkers(map, coords) { if (!map || !coords.length) return; if (coords.length === 1) { map.flyTo({ center: coords[0], zoom: 12 }); return; } const bounds = coords.reduce( (b, c) => b.extend(c), new mapboxgl.LngLatBounds(coords[0], coords[0]) ); map.fitBounds(bounds, { padding: 60, animate: true }); } export default function App() { const mapRef = useRef(null); const mapObj = useRef(null); const markerObjs = useRef([]); const places = markers?.places || []; const markerCoords = places.map((p) => p.coords); const navigate = useNavigate(); const location = useLocation(); const selectedId = React.useMemo(() => { const match = location?.pathname?.match(/(?:^|\/)place\/([^/]+)/); return match && match[1] ? match[1] : null; }, [location?.pathname]); const selectedPlace = places.find((p) => p.id === selectedId) || null; const [viewState, setViewState] = useState(() => ({ center: markerCoords.length > 0 ? markerCoords[0] : [0, 0], zoom: markerCoords.length > 0 ? 12 : 2, })); const displayMode = useOpenaiGlobal("displayMode"); const allowInspector = displayMode === "fullscreen"; const maxHeight = useMaxHeight() ?? undefined; useEffect(() => { if (mapObj.current) return; mapObj.current = new mapboxgl.Map({ container: mapRef.current, style: "mapbox://styles/mapbox/streets-v12", center: markerCoords.length > 0 ? markerCoords[0] : [0, 0], zoom: markerCoords.length > 0 ? 12 : 2, attributionControl: false, }); addAllMarkers(places); setTimeout(() => { fitMapToMarkers(mapObj.current, markerCoords); }, 0); // after first paint requestAnimationFrame(() => mapObj.current.resize()); // or keep it in sync with window resizes window.addEventListener("resize", mapObj.current.resize); return () => { window.removeEventListener("resize", mapObj.current.resize); mapObj.current.remove(); }; // eslint-disable-next-line }, []); useEffect(() => { if (!mapObj.current) return; const handler = () => { const c = mapObj.current.getCenter(); setViewState({ center: [c.lng, c.lat], zoom: mapObj.current.getZoom() }); }; mapObj.current.on("moveend", handler); return () => { mapObj.current.off("moveend", handler); }; }, []); function addAllMarkers(placesList) { markerObjs.current.forEach((m) => m.remove()); markerObjs.current = []; placesList.forEach((place) => { const marker = new mapboxgl.Marker({ color: "#F46C21", }) .setLngLat(place.coords) .addTo(mapObj.current); const el = marker.getElement(); if (el) { el.style.cursor = "pointer"; el.addEventListener("click", () => { navigate(`place/${place.id}`); panTo(place.coords, { offsetForInspector: true }); }); } markerObjs.current.push(marker); }); } function getInspectorHalfWidthPx() { if (displayMode !== "fullscreen") return 0; if (typeof window === "undefined") return 0; const isLgUp = window.matchMedia && window.matchMedia("(min-width: 1024px)").matches; if (!isLgUp) return 0; const el = document.querySelector(".pizzaz-inspector"); const w = el ? el.getBoundingClientRect().width : 360; return Math.round(w / 2); } function panTo( coord, { offsetForInspector } = { offsetForInspector: false } ) { if (!mapObj.current) return; const halfInspector = offsetForInspector ? getInspectorHalfWidthPx() : 0; const flyOpts = { center: coord, zoom: 14, speed: 1.2, curve: 1.6, }; if (halfInspector) { flyOpts.offset = [-halfInspector, 0]; } mapObj.current.flyTo(flyOpts); } useEffect(() => { if (!mapObj.current) return; addAllMarkers(places); }, [places]); // Pan the map when the selected place changes via routing useEffect(() => { if (!mapObj.current || !selectedPlace) return; panTo(selectedPlace.coords, { offsetForInspector: true }); }, [selectedId]); // Ensure Mapbox resizes when container maxHeight/display mode changes useEffect(() => { if (!mapObj.current) return; mapObj.current.resize(); }, [maxHeight, displayMode]); useEffect(() => { if ( typeof window !== "undefined" && window.oai && typeof window.oai.widget.setState === "function" ) { window.oai.widget.setState({ center: viewState.center, zoom: viewState.zoom, markers: markerCoords, }); } }, [viewState, markerCoords]); return ( <div style={{ maxHeight, height: displayMode === "fullscreen" ? maxHeight : 480, }} className={ "relative antialiased w-full min-h-[480px] overflow-hidden " + (displayMode === "fullscreen" ? "rounded-none border-0" : "border border-black/10 dark:border-white/10 rounded-2xl sm:rounded-3xl") } > <Outlet /> {displayMode !== "fullscreen" && ( <button aria-label="Enter fullscreen" className="absolute top-4 right-4 z-30 rounded-full bg-white text-black shadow-lg ring ring-black/5 p-2.5 pointer-events-auto" onClick={() => { if (selectedId) { navigate("..", { replace: true }); } if (window?.openai?.requestDisplayMode) { window.openai.requestDisplayMode({ mode: "fullscreen" }); } }} > <Maximize2 strokeWidth={1.5} className="h-4.5 w-4.5" aria-hidden="true" /> </button> )} {/* Sidebar */} <Sidebar places={places} selectedId={selectedId} onSelect={(place) => { navigate(`place/${place.id}`); panTo(place.coords, { offsetForInspector: true }); }} /> {/* Inspector (right) */} <AnimatePresence> {allowInspector && selectedPlace && ( <Inspector key={selectedPlace.id} place={selectedPlace} onClose={() => navigate("..")} /> )} </AnimatePresence> {/* Map */} <div className={ "absolute inset-0 overflow-hidden" + (displayMode === "fullscreen" ? " md:left-[340px] md:right-4 md:top-4 md:bottom-4 border border-black/10 md:rounded-3xl" : "") } > <div ref={mapRef} className="w-full h-full relative absolute bottom-0 left-0 right-0" style={{ maxHeight, height: displayMode === "fullscreen" ? maxHeight : undefined, }} /> </div> </div> ); } function RouterRoot() { return ( <Routes> <Route path="*" element={<App />}> <Route path="place/:placeId" element={<></>} /> </Route> </Routes> ); } createRoot(document.getElementById("pizzaz-root")).render( <BrowserRouter> <RouterRoot /> </BrowserRouter> ); ``` ## Pizzaz Carousel Source ![Screenshot of the Pizzaz carousel component](https://developers.openai.com/images/apps-sdk/pizzaz-carousel.png) This carousel demonstrates how to build a lightweight gallery view. It leans on embla-carousel for touch-friendly scrolling and wires up button state so the component stays reactive without any server roundtrips. ``` import useEmblaCarousel from "embla-carousel-react"; import { ArrowLeft, ArrowRight } from "lucide-react"; import React from "react"; import { Star } from "lucide-react"; import { createRoot } from "react-dom/client"; import markers from "../pizzaz/markers.json"; import PlaceCard from "./PlaceCard"; function App() { const places = markers?.places || []; const [emblaRef, emblaApi] = useEmblaCarousel({ align: "center", loop: false, containScroll: "trimSnaps", slidesToScroll: "auto", dragFree: false, }); const [canPrev, setCanPrev] = React.useState(false); const [canNext, setCanNext] = React.useState(false); React.useEffect(() => { if (!emblaApi) return; const updateButtons = () => { setCanPrev(emblaApi.canScrollPrev()); setCanNext(emblaApi.canScrollNext()); }; updateButtons(); emblaApi.on("select", updateButtons); emblaApi.on("reInit", updateButtons); return () => { emblaApi.off("select", updateButtons); emblaApi.off("reInit", updateButtons); }; }, [emblaApi]); return ( <div className="antialiased relative w-full text-black py-5"> <div className="overflow-hidden" ref={emblaRef}> <div className="flex gap-4 items-stretch"> {places.map((place) => ( <PlaceCard key={place.id} place={place} /> ))} </div> </div> {/* Edge gradients */} <div aria-hidden className={ "pointer-events-none absolute inset-y-0 left-0 w-3 z-[5] transition-opacity duration-200 " + (canPrev ? "opacity-100" : "opacity-0") } > <div className="h-full w-full border-l border-black/15 bg-gradient-to-r from-black/10 to-transparent" style={{ WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, white 30%, white 70%, transparent 100%)", maskImage: "linear-gradient(to bottom, transparent 0%, white 30%, white 70%, transparent 100%)", }} /> </div> <div aria-hidden className={ "pointer-events-none absolute inset-y-0 right-0 w-3 z-[5] transition-opacity duration-200 " + (canNext ? "opacity-100" : "opacity-0") } > <div className="h-full w-full border-r border-black/15 bg-gradient-to-l from-black/10 to-transparent" style={{ WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, white 30%, white 70%, transparent 100%)", maskImage: "linear-gradient(to bottom, transparent 0%, white 30%, white 70%, transparent 100%)", }} /> </div> {canPrev && ( <button aria-label="Previous" className="absolute left-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center h-8 w-8 rounded-full bg-white text-black shadow-lg ring ring-black/5 hover:bg-white" onClick={() => emblaApi && emblaApi.scrollPrev()} type="button" > <ArrowLeft strokeWidth={1.5} className="h-4.5 w-4.5" aria-hidden="true" /> </button> )} {canNext && ( <button aria-label="Next" className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center h-8 w-8 rounded-full bg-white text-black shadow-lg ring ring-black/5 hover:bg-white" onClick={() => emblaApi && emblaApi.scrollNext()} type="button" > <ArrowRight strokeWidth={1.5} className="h-4.5 w-4.5" aria-hidden="true" /> </button> )} </div> ); } createRoot(document.getElementById("pizzaz-carousel-root")).render(<App />); export default function PlaceCard({ place }) { if (!place) return null; return ( <div className="min-w-[220px] select-none max-w-[220px] w-[65vw] sm:w-[220px] self-stretch flex flex-col"> <div className="w-full"> <img src={place.thumbnail} alt={place.name} className="w-full aspect-square rounded-2xl object-cover ring ring-black/5 shadow-[0px_2px_6px_rgba(0,0,0,0.06)]" /> </div> <div className="mt-3 flex flex-col flex-1 flex-auto"> <div className="text-base font-medium truncate line-clamp-1"> {place.name} </div> <div className="text-xs mt-1 text-black/60 flex items-center gap-1"> <Star className="h-3 w-3" aria-hidden="true" /> {place.rating?.toFixed ? place.rating.toFixed(1) : place.rating} {place.price ? <span>· {place.price}</span> : null} <span>· San Francisco</span> </div> {place.description ? ( <div className="text-sm mt-2 text-black/80 flex-auto"> {place.description} </div> ) : null} <div className="mt-5"> <button type="button" className="cursor-pointer inline-flex items-center rounded-full bg-[#F46C21] text-white px-4 py-1.5 text-sm font-medium hover:opacity-90 active:opacity-100" > Order now </button> </div> </div> </div> ); } ``` ## Pizzaz List Source ![Screenshot of the Pizzaz list component](https://developers.openai.com/images/apps-sdk/pizzaz-list.png) This list layout mirrors what you might embed in a chat-initiated itinerary or report. It balances a hero summary with a scrollable ranking so you can experiment with denser information hierarchies inside a component. ``` import React from "react"; import { createRoot } from "react-dom/client"; import markers from "../pizzaz/markers.json"; import { PlusCircle, Star } from "lucide-react"; function App() { const places = markers?.places || []; return ( <div className="antialiased w-full text-black px-4 pb-2 border border-black/10 dark:border-white/10 rounded-2xl sm:rounded-3xl overflow-hidden"> <div className="max-w-full"> <div className="flex flex-row items-center gap-4 sm:gap-4 border-b border-black/5 py-4"> <div className="sm:w-18 w-16 aspect-square rounded-xl bg-cover bg-center" style={{ backgroundImage: "url(https://plus.unsplash.com/premium_photo-1675884306775-a0db978623a0?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NDV8fHBpenphJTIwd2FsbHBhcGVyfGVufDB8fDB8fHww)", }} ></div> <div> <div className="text-base sm:text-xl font-medium"> National Best Pizza List </div> <div className="text-sm text-black/60"> A ranking of the best pizzerias in the world </div> </div> <div className="flex-auto hidden sm:flex justify-end pr-2"> <button type="button" className="cursor-pointer inline-flex items-center rounded-full bg-[#F46C21] text-white px-4 py-1.5 sm:text-md text-sm font-medium hover:opacity-90 active:opacity-100" > Save List </button> </div> </div> <div className="min-w-full text-sm flex flex-col"> {places.slice(0, 7).map((place, i) => ( <div key={place.id} className="px-3 -mx-2 rounded-2xl hover:bg-black/5" > <div style={{ borderBottom: i === 7 - 1 ? "none" : "1px solid rgba(0, 0, 0, 0.05)", }} className="flex w-full items-center hover:border-black/0! gap-2" > <div className="py-3 pr-3 min-w-0 w-full sm:w-3/5"> <div className="flex items-center gap-3"> <img src={place.thumbnail} alt={place.name} className="h-10 w-10 sm:h-11 sm:w-11 rounded-lg object-cover ring ring-black/5" /> <div className="w-3 text-end sm:block hidden text-sm text-black/40"> {i + 1} </div> <div className="min-w-0 sm:pl-1 flex flex-col items-start h-full"> <div className="font-medium text-sm sm:text-md truncate max-w-[40ch]"> {place.name} </div> <div className="mt-1 sm:mt-0.25 flex items-center gap-3 text-black/70 text-sm"> <div className="flex items-center gap-1"> <Star strokeWidth={1.5} className="h-3 w-3 text-black" /> <span> {place.rating?.toFixed ? place.rating.toFixed(1) : place.rating} </span> </div> <div className="whitespace-nowrap sm:hidden"> {place.city || "–"} </div> </div> </div> </div> </div> <div className="hidden sm:block text-end py-2 px-3 text-sm text-black/60 whitespace-nowrap flex-auto"> {place.city || "–"} </div> <div className="py-2 whitespace-nowrap flex justify-end"> <PlusCircle strokeWidth={1.5} className="h-5 w-5" /> </div> </div> </div> ))} {places.length === 0 && ( <div className="py-6 text-center text-black/60"> No pizzerias found. </div> )} </div> <div className="sm:hidden px-0 pt-2 pb-2"> <button type="button" className="w-full cursor-pointer inline-flex items-center justify-center rounded-full bg-[#F46C21] text-white px-4 py-2 font-medium hover:opacity-90 active:opacity-100" > Save List </button> </div> </div> </div> ); } createRoot(document.getElementById("pizzaz-list-root")).render(<App />); ``` ## Pizzaz Video Source The video component wraps a scripted player that tracks playback, overlays controls, and reacts to fullscreen changes. Use it as a reference for media-heavy experiences that still need to integrate with the ChatGPT container APIs. ``` import { Maximize2, Play } from "lucide-react"; import React from "react"; import { createRoot } from "react-dom/client"; import { useMaxHeight } from "../use-max-height"; import { useOpenaiGlobal } from "../use-openai-global"; import script from "./script.json"; function App() { return ( <div className="antialiased w-full text-black"> <VideoPlayer /> </div> ); } createRoot(document.getElementById("pizzaz-video-root")).render(<App />); export default function VideoPlayer() { const videoRef = React.useRef(null); const [showControls, setShowControls] = React.useState(false); const [showOverlayPlay, setShowOverlayPlay] = React.useState(true); const [isPlaying, setIsPlaying] = React.useState(false); const lastBucketRef = React.useRef(null); const isPlayingRef = React.useRef(false); const [activeTab, setActiveTab] = React.useState("summary"); const [currentTime, setCurrentTime] = React.useState(0); const VIDEO_DESCRIPTION = "President Obama delivered his final weekly address thanking the American people for making him a better President and a better man."; const displayMode = useOpenaiGlobal("displayMode"); const isFullscreen = displayMode === "fullscreen"; const maxHeight = useMaxHeight() ?? undefined; const timeline = React.useMemo(() => { function toSeconds(ts) { if (!ts) return 0; const parts = String(ts).split(":"); const [mm, ss] = parts.length === 2 ? parts : ["0", "0"]; const m = Number(mm) || 0; const s = Number(ss) || 0; return m * 60 + s; } return Array.isArray(script) ? script.map((item) => ({ start: toSeconds(item.start), end: toSeconds(item.end), description: item.description || "", })) : []; }, []); function formatSeconds(totalSeconds) { const total = Math.max(0, Math.floor(Number(totalSeconds) || 0)); const minutes = Math.floor(total / 60); const seconds = total % 60; const pad = (n) => String(n).padStart(2, "0"); return `${pad(minutes)}:${pad(seconds)}`; } const findDescriptionForTime = React.useCallback( (t) => { for (let i = 0; i < timeline.length; i++) { const seg = timeline[i]; if (t >= seg.start && t < seg.end) { return seg.description || ""; } } return ""; }, [timeline] ); const sendDescriptionForTime = React.useCallback( (t, { force } = { force: false }) => { const bucket = Math.floor(Number(t || 0) / 10); if (!force && bucket === lastBucketRef.current) return; lastBucketRef.current = bucket; const desc = findDescriptionForTime(Number(t || 0)); if ( typeof window !== "undefined" && window.oai && window.oai.widget && typeof window.oai.widget.setState === "function" ) { window.oai.widget.setState({ currentSceneDescription: desc, videoDescription: VIDEO_DESCRIPTION, }); } }, [findDescriptionForTime] ); async function handlePlayClick() { setShowOverlayPlay(false); setShowControls(true); try { if (displayMode === "inline") { await window?.openai?.requestDisplayMode?.({ mode: "pip" }); } } catch {} try { await videoRef.current?.play?.(); } catch {} } React.useEffect(() => { const el = videoRef.current; if (!el) return; function handlePlay() { setIsPlaying(true); isPlayingRef.current = true; // Immediate update on play sendDescriptionForTime(el.currentTime, { force: true }); setCurrentTime(el.currentTime); } function handlePause() { setIsPlaying(false); isPlayingRef.current = false; } function handleEnded() { setIsPlaying(false); isPlayingRef.current = false; } function handleTimeUpdate() { if (!isPlayingRef.current) return; sendDescriptionForTime(el.currentTime); setCurrentTime(el.currentTime); } function handleSeeking() { // Update immediately while user scrubs or jumps sendDescriptionForTime(el.currentTime, { force: true }); setCurrentTime(el.currentTime); } function handleSeeked() { // Ensure we reflect the final position after seek completes sendDescriptionForTime(el.currentTime, { force: true }); setCurrentTime(el.currentTime); } el.addEventListener("play", handlePlay); el.addEventListener("pause", handlePause); el.addEventListener("ended", handleEnded); el.addEventListener("timeupdate", handleTimeUpdate); el.addEventListener("seeking", handleSeeking); el.addEventListener("seeked", handleSeeked); return () => { el.removeEventListener("play", handlePlay); el.removeEventListener("pause", handlePause); el.removeEventListener("ended", handleEnded); el.removeEventListener("timeupdate", handleTimeUpdate); el.removeEventListener("seeking", handleSeeking); el.removeEventListener("seeked", handleSeeked); }; }, [sendDescriptionForTime]); // If the host returns the component to inline mode, pause and show the overlay play button React.useEffect(() => { if (displayMode !== "inline") return; try { videoRef.current?.pause?.(); } catch {} setIsPlaying(false); isPlayingRef.current = false; setShowControls(false); setShowOverlayPlay(true); }, [displayMode]); return ( <div className="relative w-full bg-white group" style={{ aspectRatio: "16 / 9", maxHeight }} > <div className={ isFullscreen ? "flex flex-col lg:flex-row w-full h-full gap-4 p-4" : "w-full h-full" } > {/* Left: Video */} <div className={ isFullscreen ? "relative flex-1 h-full" : "relative w-full h-full" } > <div style={{ aspectRatio: "16 / 9" }} className="relative w-full"> <video ref={videoRef} className={ "absolute inset-0 w-full h-auto" + (isFullscreen ? " shadow-lg rounded-3xl" : "") } controls={showControls} playsInline preload="metadata" aria-label="How to make pizza" > <source src="https://obamawhitehouse.archives.gov/videos/2017/January/20170114_Weekly_Address_HD.mp4#t=8" type="video/mp4" /> Your browser does not support the video tag. </video> <div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none"> {showOverlayPlay && ( <button type="button" aria-label="Play video" className="h-20 w-20 backdrop-blur-xl bg-black/40 ring ring-black/20 shadow-xl rounded-full text-white flex items-center justify-center transition pointer-events-auto" onClick={handlePlayClick} > <Play strokeWidth={1.5} className="h-10 w-10" aria-hidden="true" /> </button> )} </div> </div> {displayMode !== "fullscreen" && ( <button aria-label="Enter fullscreen" className="absolute top-3 right-3 z-20 rounded-full bg-black/30 backdrop-blur-2xl text-white p-2 pointer-events-auto" onClick={() => { if ( displayMode !== "fullscreen" && window?.openai?.requestDisplayMode ) { window.openai.requestDisplayMode({ mode: "fullscreen" }); } }} > <Maximize2 strokeWidth={1.5} className="h-4.5 w-4.5" aria-hidden="true" /> </button> )} {/* Hover title overlay (hidden in fullscreen) */} {!isFullscreen && ( <div className="absolute left-2 right-0 bottom-18 pointer-events-none flex justify-start"> <div className="text-white px-3 py-1 transition-opacity duration-150 opacity-0 group-hover:opacity-100"> <div className="text-sm font-medium text-white/60"> Weekly Address </div> <div className="text-2xl font-medium"> The Honor of Serving You as President </div> </div> </div> )} </div> {/* Right: Details panel (fullscreen only) */} {isFullscreen && ( <div className="w-full lg:w-[364px] px-4 h-full flex flex-col"> <div className="text-sm mt-4 text-black/60">Weekly Address</div> <div className="text-3xl leading-tighter font-medium text-black mt-4"> The Honor of Serving You as President </div> <div className="mt-4 flex items-center gap-3 text-sm text-black/70"> <img src="https://upload.wikimedia.org/wikipedia/commons/8/8d/President_Barack_Obama.jpg" alt="Barack Obama portrait" className="h-8 translate-y-[1px] w-8 rounded-full object-cover ring-1 ring-black/10" /> <div className="flex flex-col h-full"> <span className="text-sm font-medium">President Obama</span> <span className="text-sm text-black/60">January 13, 2017</span> </div> </div> <div className="mt-8 inline-flex rounded-full bg-black/5 p-1"> <button type="button" className={ "px-3 py-1.5 text-sm font-medium rounded-full flex-auto transition " + (activeTab === "summary" ? "bg-white shadow text-black" : "text-black/60 hover:text-black") } onClick={() => setActiveTab("summary")} > Summary </button> <button type="button" className={ "ml-1 px-3 py-1.5 font-medium text-sm flex-auto rounded-full transition " + (activeTab === "transcript" ? "bg-white shadow text-black" : "text-black/60 hover:text-black") } onClick={() => setActiveTab("transcript")} > Transcript </button> </div> <div className="mt-5 text-sm overflow-auto pb-32 text-black/80" style={{ WebkitMaskImage: "linear-gradient(to bottom, black 75%, rgba(0,0,0,0) 100%)", maskImage: "linear-gradient(to bottom, black 75%, rgba(0,0,0,0) 100%)", }} > {activeTab === "summary" ? ( <p> <p> This week, President Obama delivered his final weekly address thanking the American people for making him a better President and a better man. Over the past eight years, we have seen the goodness, resilience, and hope of the American people. We’ve seen what’s possible when we come together in the hard, but vital work of self-government – but we can’t take our democracy for granted. Our success as a Nation depends on our participation. </p> <p className="mt-6"> It’s up to all of us to be guardians of our democracy, and to embrace the task of continually trying to improve our Nation. Despite our differences, we all share the same title: Citizen. And that is why President Obama looks forward to working by your side, as a citizen, for all of his remaining days. </p> </p> ) : ( <div> {timeline.map((seg, idx) => { const isActive = currentTime >= seg.start && currentTime < seg.end; return ( <p key={idx} className={ "px-2 py-1 rounded-md my-0.5 transition-colors transition-opacity duration-300 flex items-start gap-2 " + (isActive ? "bg-black/5 opacity-100" : "bg-transparent opacity-80") } > <span className="text-xs text-black/40 tabular-nums leading-5 mt-0.5 mr-1"> {formatSeconds(seg.start)} </span> <span className="flex-1">{seg.description}</span> </p> ); })} </div> )} </div> </div> )} </div> </div> ); } import React from "react"; import { motion } from "framer-motion"; import { Star, X } from "lucide-react"; export default function Inspector({ place, onClose }) { if (!place) return null; return ( <motion.div key={place.id} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ type: "spring", bounce: 0, duration: 0.25 }} className="pizzaz-inspector absolute inset-0 z-30 w-full lg:absolute lg:inset-auto lg:top-8 lg:bottom-8 lg:right-8 lg:z-20 lg:w-[360px] lg:max-w-[75%] pointer-events-auto" > <button aria-label="Close details" className="hidden lg:inline-flex absolute z-10 top-4 left-4 rounded-full p-2 bg-white ring ring-black/5 shadow-2xl hover:bg-white" onClick={onClose} > <X className="h-[18px] w-[18px]" aria-hidden="true" /> </button> <div className="relative h-full overflow-y-auto rounded-none lg:rounded-3xl bg-white text-black shadow-xl ring ring-black/10"> <div className="relative"> <img src={place.thumbnail} alt={place.name} className="w-full h-80 object-cover rounded-none lg:rounded-t-2xl" /> </div> <div className="h-[calc(100%-11rem)] sm:h-[calc(100%-14rem)]"> <div className="p-4 sm:p-5"> <div className="text-2xl font-medium truncate">{place.name}</div> <div className="text-sm mt-1 opacity-70 flex items-center gap-1"> <Star className="h-3.5 w-3.5" aria-hidden="true" /> {place.rating.toFixed(1)} {place.price ? <span>· {place.price}</span> : null} <span>· San Francisco</span> </div> <div className="mt-3 flex flex-row items-center gap-3 font-medium"> <div className="rounded-full bg-[#F46C21] text-white cursor-pointer px-4 py-1.5">Order Online</div> <div className="rounded-full border border-[#F46C21]/50 text-[#F46C21] cursor-pointer px-4 py-1.5">Contact</div> </div> <div className="text-sm mt-5"> {place.description} Enjoy a slice at one of SF's favorites. Fresh ingredients, great crust, and cozy vibes. </div> </div> <div className="px-4 sm:px-5 pb-4"> <div className="text-lg font-medium mb-2">Reviews</div> <ul className="space-y-3 divide-y divide-black/5"> {[\ {\ user: "Alex M.",\ avatar: "https://i.pravatar.cc/40?img=3",\ text: "Fantastic crust and balanced toppings. The marinara is spot on!",\ },\ {\ user: "Priya S.",\ avatar: "https://i.pravatar.cc/40?img=5",\ text: "Cozy vibe and friendly staff. Quick service on a Friday night.",\ },\ {\ user: "Jordan R.",\ avatar: "https://i.pravatar.cc/40?img=8",\ text: "Great for sharing. Will definitely come back with friends.",\ },\ ].map((review, idx) => ( <li key={idx} className="py-3"> <div className="flex items-start gap-3"> <img src={review.avatar} alt={`${review.user} avatar`} className="h-8 w-8 ring ring-black/5 rounded-full object-cover flex-none" /> <div className="min-w-0 gap-1 flex flex-col"> <div className="text-xs font-medium text-black/70">{review.user}</div> <div className="text-sm">{review.text}</div> </div> </div> </li> ))} </ul> </div> </div> </div> </motion.div> ); } import React from "react"; import useEmblaCarousel from "embla-carousel-react"; import { useOpenaiGlobal } from "../use-openai-global"; import { Filter, Settings2, Star } from "lucide-react"; function PlaceListItem({ place, isSelected, onClick }) { return ( <div className={ "rounded-2xl px-3 select-none hover:bg-black/5 cursor-pointer" + (isSelected ? " bg-black/5" : "") } > <div className={`border-b ${ isSelected ? "border-black/0" : "border-black/5" } hover:border-black/0`} > <button className="w-full text-left py-3 transition flex gap-3 items-center" onClick={onClick} > <img src={place.thumbnail} alt={place.name} className="h-16 w-16 rounded-lg object-cover flex-none" /> <div className="min-w-0"> <div className="font-medium truncate">{place.name}</div> <div className="text-xs text-black/50 truncate"> {place.description} </div> <div className="text-xs mt-1 text-black/50 flex items-center gap-1"> <Star className="h-3 w-3" aria-hidden="true" /> {place.rating.toFixed(1)} {place.price ? <span className="">· {place.price}</span> : null} </div> </div> </button> </div> </div> ); } export default function Sidebar({ places, selectedId, onSelect }) { const [emblaRef] = useEmblaCarousel({ dragFree: true, loop: false }); const displayMode = useOpenaiGlobal("displayMode"); const forceMobile = displayMode !== "fullscreen"; return ( <> {/* Desktop/Tablet sidebar */} <div className={`${forceMobile ? "hidden" : "hidden md:block"} absolute inset-y-0 left-0 z-20 w-[340px] max-w-[75%] pointer-events-auto`}> <div className="px-2 h-full overflow-y-auto bg-white text-black"> <div className="flex justify-between flex-row items-center px-3 sticky bg-white top-0 py-4 text-md font-medium"> {places.length} results <div> <Settings2 className="h-5 w-5" aria-hidden="true" /> </div> </div> <div> {places.map((place) => ( <PlaceListItem key={place.id} place={place} isSelected={displayMode === "fullscreen" && selectedId === place.id} onClick={() => onSelect(place)} /> ))} </div> </div> </div> {/* Mobile bottom carousel */} <div className={`${forceMobile ? "" : "md:hidden"} absolute inset-x-0 bottom-0 z-20 pointer-events-auto`}> <div className="pt-2 text-black"> <div className="overflow-hidden" ref={emblaRef}> <div className="px-3 py-3 flex gap-3"> {places.map((place) => ( <div className="ring ring-black/10 max-w-[330px] w-full shadow-xl rounded-2xl bg-white"> <PlaceListItem key={place.id} place={place} isSelected={displayMode === "fullscreen" && selectedId === place.id} onClick={() => onSelect(place)} /> </div> ))} </div> </div> </div> </div> </> ); } { "places": [\ {\ "id": "tonys-pizza-napoletana",\ "name": "Tony's Pizza Napoletana",\ "coords": [-122.4098, 37.8001],\ "description": "Award‑winning Neapolitan pies in North Beach.",\ "city": "North Beach",\ "rating": 4.8,\ "price": "$$$",\ "thumbnail": "https://images.unsplash.com/photo-1513104890138-7c749659a591?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"\ },\ {\ "id": "golden-boy-pizza",\ "name": "Golden Boy Pizza",\ "coords": [-122.4093, 37.7990],\ "description": "Focaccia‑style squares, late‑night favorite.",\ "city": "North Beach",\ "rating": 4.6,\ "price": "$",\ "thumbnail": "https://plus.unsplash.com/premium_photo-1661762555601-47d088a26b50?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8OXx8cGl6emF8ZW58MHx8MHx8fDA%3D"\ },\ {\ "id": "pizzeria-delfina-mission",\ "name": "Pizzeria Delfina (Mission)",\ "coords": [-122.4255, 37.7613],\ "description": "Thin‑crust classics on 18th Street.",\ "city": "Mission",\ "rating": 4.5,\ "price": "$$",\ "thumbnail": "https://images.unsplash.com/photo-1571997478779-2adcbbe9ab2f?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8OHx8cGl6emF8ZW58MHx8MHx8fDA%3D"\ },\ {\ "id": "little-star-divisadero",\ "name": "Little Star Pizza",\ "coords": [-122.4388, 37.7775],\ "description": "Deep‑dish and cornmeal crust favorites.",\ "city": "Alamo Square",\ "rating": 4.5,\ "price": "$$",\ "thumbnail": "https://images.unsplash.com/photo-1579751626657-72bc17010498?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTB8fHBpenphfGVufDB8fDB8fHww"\ },\ {\ "id": "il-casaro-columbus",\ "name": "Il Casaro Pizzeria",\ "coords": [-122.4077, 37.7990],\ "description": "Wood‑fired pies and burrata in North Beach.",\ "city": "North Beach",\ "rating": 4.6,\ "price": "$$",\ "thumbnail": "https://images.unsplash.com/photo-1594007654729-407eedc4be65?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTF8fHBpenphfGVufDB8fDB8fHww"\ },\ {\ "id": "capos",\ "name": "Capo's",\ "coords": [-122.4097, 37.7992],\ "description": "Chicago‑style pies from Tony Gemignani.",\ "city": "North Beach",\ "rating": 4.4,\ "price": "$$$",\ "thumbnail": "https://images.unsplash.com/photo-1593560708920-61dd98c46a4e?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTV8fHBpenphfGVufDB8fDB8fHww"\ },\ {\ "id": "ragazza",\ "name": "Ragazza",\ "coords": [-122.4380, 37.7722],\ "description": "Neighborhood spot with seasonal toppings.",\ "city": "Lower Haight",\ "rating": 4.4,\ "price": "$$",\ "thumbnail": "https://images.unsplash.com/photo-1600028068383-ea11a7a101f3?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTl8fHBpenphfGVufDB8fDB8fHww"\ },\ {\ "id": "del-popolo",\ "name": "Del Popolo",\ "coords": [-122.4123, 37.7899],\ "description": "Sourdough, wood‑fired pies near Nob Hill.",\ "city": "Nob Hill",\ "rating": 4.6,\ "price": "$$$",\ "thumbnail": "https://images.unsplash.com/photo-1574071318508-1cdbab80d002?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MjZ8fHBpenphfGVufDB8fDB8fHww"\ },\ {\ "id": "square-pie-guys",\ "name": "Square Pie Guys",\ "coords": [-122.4135, 37.7805],\ "description": "Crispy‑edged Detroit‑style in SoMa.",\ "city": "SoMa",\ "rating": 4.5,\ "price": "$$",\ "thumbnail": "https://images.unsplash.com/photo-1589187151053-5ec8818e661b?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mzl8fHBpenphfGVufDB8fDB8fHww"\ },\ {\ "id": "zero-zero",\ "name": "Zero Zero",\ "coords": [-122.4019, 37.7818],\ "description": "Bianca pies and cocktails near Yerba Buena.",\ "city": "Yerba Buena",\ "rating": 4.3,\ "price": "$$",\ "thumbnail": "https://plus.unsplash.com/premium_photo-1674147605295-53b30e11d8c0?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NDF8fHBpenphfGVufDB8fDB8fHww"\ }\ ] } ```

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/opgginc/opgg-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server