Apps SDKでシンプルなChatGPTアプリを作る

Apps SDKを使うとChatGPTで動くアプリを作ることができます。

ChatGPTに尋ねるとカフェの店舗一覧がUIつきで表示されている

今回は「Z Coffee」というカフェを想定し、ユーザーとのやり取りに応じて店舗情報を表示するアプリを作ってみました。

これをどのように実装したのかをまとめてみます。

OpenAIが提供する仕組みで、ChatGPTとの会話の中で3rd partyが作成したアプリをリッチなUI付きで表示できるものです。
例えば「渋谷付近でホテルを予約したい」とユーザーが尋ねた際、Booking.comのアプリが反応して渋谷近辺のホテルの一覧を画像付きで表示する、みたいな感じです。

ChatGPT Appsのデザイン例。カルーセルやフルスクリーンなどのいろんなパターンが描かれている
UIは柔軟にデザインできる

UIはかなり自由度高く表現できます。リストやカルーセルはもちろん、フルスクリーンやPIP(Picture in Picture)も可能です。

ここでの表現はChatGPTの体験を左右するため、デザインガイドラインが公開されています。詳しくまとまっているので一読しておきましょう。読み物としても面白いです。

MCPと何が違うの?

  • リッチなUI表現ができる
  • ユーザーが事前に設定しなくても使える
    • 会話の内容に応じてChatGPTが判断し、使った方が良いと思えるアプリを推薦する

もう使えるの?

  • まだプレビュー版(2025/11/6時点)
  • 2025年後半に審査受付開始、アプリ収益化の方法についてアナウンスがあると発表されている
  • 今時点ではApps SDKを使った開発 + ChatGPT環境で試すところまではできる

その他

  • Apps SDKはMCPをベースにしている
    • MCP + UIでイメージすると分かりやすいかも
    • UI部分はReactなどの慣れた技術で作れる
  • ChatGPTで試すには有料プランが必要
    • Buinessプランなどのビジネスアカウントでは使えず、個人プランが必要
    • この辺りは状況変わりそうなので要ウォッチ。このIssueに議論がよくまとまってます

より具体的にイメージするため、実際にChatGPT Appsを作っていきましょう。
今回作るのは冒頭で見たカフェの店舗一覧を返すアプリです。

データを用意する

まずは店舗のデータを用意します。
API経由で取得できると便利なため、今回はデータを microCMS で管理しましょう。

今回はこんな感じでデータを入れました。

microCMSの管理画面。店舗情報が入稿されている
店舗情報をmicroCMSに入稿

APIリクエストでは例えばこんな感じでデータを取得できます。

// curl "https://z-coffee.microcms.io/api/v1/stores" -H "X-MICROCMS-API-KEY: "

{
  "contents": [
    {
      "id": "ps5gcbidm7-l",
      "createdAt": "2025-10-28T08:51:17.841Z",
      "name": "恵比寿店",
      "address": "東京都渋谷区恵比寿1-2-3",
      "description": "緑の多さが特徴の店舗です。植物とコーヒーを楽しんでください。",
      "review": 4.3,
      "image": {
        "url": "https://images.microcms-assets.io/assets/d7b6e3b4b6854706aaaa12421e654cd9/bed3b9fd4ec44555b8901fb91ce6feb6/image.png",
        "height": 1024,
        "width": 1536
      }
    },
    {
      "id": "hsotbprdnovj",
      "createdAt": "2025-10-28T08:51:16.875Z",
      "name": "中目黒店",
      // ...(省略)

これはシンプルなリクエスト例ですが、microCMSにデータを入れておけば並び替えや絞り込みなども簡単にできるのでオススメです。

次はこのAPIをラップする形でMCPサーバーを実装していきましょう。

MCPサーバーを実装する

MCPサーバーはPythonあるいはTypeScriptの公式SDKで開発することが推奨されています。
今回はTypeScript SDKを選びました。

MCPサーバーのツールを実装します。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { createClient } from "microcms-js-sdk";
import { StreamableHTTPTransport } from "@hono/mcp";
import { Hono } from "hono";
import { serve } from "@hono/node-server";

const server = new McpServer({
    name: "store-app",
    version: "0.1.0",
});

const serviceDomain = process.env.MICROCMS_SERVICE_DOMAIN;
const apiKey = process.env.MICROCMS_API_KEY;

let microcmsClient: ReturnType | null = null;
try {
    microcmsClient = serviceDomain && apiKey
        ? createClient({ serviceDomain, apiKey })
        : null;
} catch {
    microcmsClient = null;
}

type OutStore = { id: string; name: string; address: string; review: number; image: string; description: string };

// microCMSからデータを取得
const fetchStores = async (addressQuery?: string): Promise => {
    if (!microcmsClient) {
        return [];
    }
    try {
        const queries: any = { limit: 50, orders: "-publishedAt" };
        if (addressQuery && addressQuery.trim() !== "") {
            queries.filters = `address[contains]${addressQuery}`;
        }
        const res = await microcmsClient.getList({ endpoint: "stores", queries });
        return (res.contents ?? []).map((c: any) => ({
            id: String(c.id),
            name: c.name ?? "",
            address: c.address ?? "",
            review: typeof c.review === "number" ? c.review : 0,
            image: (c.image && typeof c.image === "object" && c.image.url) ? c.image.url : "",
            description: c.description ?? "",
        }));
    } catch {
        return [];
    }
};

// 店舗一覧を取得するツール
server.registerTool(
    "store-list",
    {
        title: "Show Store List",
        inputSchema: {
            address: z.string().optional(),
        },
        outputSchema: {
            stores: z.array(
                z.object({
                    id: z.string(),
                    name: z.string(),
                    address: z.string(),
                    review: z.number(),
                    image: z.string(),
                    description: z.string(),
                })
            ),
        },
    },
    async (params: { address?: string } | undefined) => {
        const stores = await fetchStores(params?.address);
        return {
            content: [{ type: "text", text: JSON.stringify(stores) }],
            structuredContent: { stores },
        };
    }
);

const app = new Hono();

app.get("https://zenn.dev/", (c) => {
    return c.text("Hello, MCP Server is available at /mcp, yah!");
});

app.all("/mcp", async (c) => {
    const transport = new StreamableHTTPTransport();
    await server.connect(transport);
    return transport.handleRequest(c);
});

serve(app);

実装できたら期待通り動くか試してみましょう。

まずは以下のコマンドでサーバーを起動します。

確認にはMCP Inspectorを使います。別タブを開いて以下のコマンドを実行しましょう。

npx @modelcontextprotocol/inspector

MCP Inspectorの画面。List Toolsが実行され、store-listの情報が表示されている
MCP InspectorでList Toolsを実行

Tools > List Tools を実行し、store-list ツールが表示されて動いていれば確認はOKです。

UIを実装する

次にUI部分を実装していきましょう。

前準備

必要なファイルを準備していきます。

ディレクトリ構成はこんな感じです。
ディレクトリ構成。MCP系がserver/に、UI系がweb/にまとめられている
ディレクトリ構成。server/にMCPサーバー系、web/にUI系の実装がある

index.css を以下の内容で作成。

types.ts を以下の内容で作成。

export type OpenAiGlobals = {
    // visuals
    theme: Theme;

    userAgent: UserAgent;
    locale: string;

    // layout
    maxHeight: number;
    displayMode: DisplayMode;
    safeArea: SafeArea;

    // state
    toolInput: ToolInput;
    toolOutput: ToolOutput | null;
    toolResponseMetadata: ToolResponseMetadata | null;
    widgetState: WidgetState | null;
    setWidgetState: (state: WidgetState) => Promise;
};

// currently copied from types.ts in chatgpt/web-sandbox.
// Will eventually use a public package.
type API = {
    callTool: CallTool;
    sendFollowUpMessage: (args: { prompt: string }) => Promise;
    openExternal(payload: { href: string }): void;

    // Layout controls
    requestDisplayMode: RequestDisplayMode;
};

export type UnknownObject = Record;

export type Theme = "light" | "dark";

export type SafeAreaInsets = {
    top: number;
    bottom: number;
    left: number;
    right: number;
};

export type SafeArea = {
    insets: SafeAreaInsets;
};

export type DeviceType = "mobile" | "tablet" | "desktop" | "unknown";

export type UserAgent = {
    device: { type: DeviceType };
    capabilities: {
        hover: boolean;
        touch: boolean;
    };
};

/** Display mode */
export type DisplayMode = "pip" | "inline" | "fullscreen";
export type RequestDisplayMode = (args: { mode: DisplayMode }) => Promise;

export type CallToolResponse = {
    result: string;
};

/** Calling APIs */
export type CallTool = (
    name: string,
    args: Record
) => Promise;

/** Extra events */
export const SET_GLOBALS_EVENT_TYPE = "openai:set_globals";
export class SetGlobalsEvent extends CustomEvent;
}> {
    readonly type = SET_GLOBALS_EVENT_TYPE;
}

/**
 * Global oai object injected by the web sandbox for communicating with chatgpt host page.
 */
declare global {
    interface Window {
        openai: API & OpenAiGlobals;
    }

    interface WindowEventMap {
        [SET_GLOBALS_EVENT_TYPE]: SetGlobalsEvent;
    }
}

export type Store = {
    id: string;
    name: string;
    address: string;
    review: number;
    image: string;
    description: string;
};

useOpenAi.ts を以下の内容で作成。

import { useSyncExternalStore } from "react";
import {
    SET_GLOBALS_EVENT_TYPE,
    SetGlobalsEvent,
    type OpenAiGlobals,
} from "./types";

export function useOpenAiGlobal(
    key: K
): OpenAiGlobals[K] | null {
    return useSyncExternalStore(
        (onChange) => {
            if (typeof window === "undefined") {
                return () => { };
            }

            const handleSetGlobal = (event: SetGlobalsEvent) => {
                const value = event.detail.globals[key];
                if (value === undefined) {
                    return;
                }

                onChange();
            };

            window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, {
                passive: true,
            });

            return () => {
                window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal);
            };
        },
        () => window.openai?.[key] ?? null,
        () => window.openai?.[key] ?? null
    );
}

tsconfig.json を以下の内容で作成。

{
    "compilerOptions": {
        "target": "ESNext",
        "useDefineForClassFields": true,
        "lib": [
            "DOM",
            "DOM.Iterable",
            "ESNext"
        ],
        "allowJs": false,
        "skipLibCheck": true,
        "esModuleInterop": false,
        "allowSyntheticDefaultImports": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "module": "ESNext",
        "moduleResolution": "Bundler",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "react-jsx",
        "allowImportingTsExtensions": true
    },
    "include": [
        "src",
        "build.mjs"
    ]
}

店舗情報のUIを実装する

準備ができたので実際のUI部分を実装していきます。

UIはこんな感じのものを組んでいきます。
店舗がひとつずつカードになっており、横並びになっている
店舗を表示するUIのイメージ

まず、店舗カードを store-list/StoreListItem.tsx に作成します:

import React from "react";
import { Store } from "../types";

type StoreListItemProps = {
    store: Store;
    className?: string;
};

export const StoreListItem: React.FC = ({ store, className }) => {
    return (
        
  • {store.name}

    {store.name}

    {store.review.toFixed(1)} {store.address && ·} {store.address}

    {store.description}

  • ); };

    次に店舗一覧を store-list/StoreList.tsx に作成します:

    import React from "react";
    import { useOpenAiGlobal } from "../useOpenAi";
    import { Store } from "../types";
    import { StoreListItem } from "./StoreListItem.tsx";
    
    export const StoreList: React.FC = () => {
        const toolOutput = useOpenAiGlobal("toolOutput");
        const initialStores = toolOutput
            ? toolOutput.stores as Store[]
            : [];
    
        const [stores, setStores] = React.useState(initialStores);
    
        React.useEffect(() => {
            if (toolOutput?.stores) {
                setStores(toolOutput.stores);
            }
        }, [toolOutput]);
    
        return (
            
      {stores.map((store) => ( ))}
    ); };

    最後に store-list/index.ts を作成します:

    import React from "react";
    import { createRoot } from "react-dom/client";
    import { StoreList } from "./StoreList";
    import "../index.css";
    
    const container = document.getElementById("store-list-root");
    if (container) {
        const root = createRoot(container);
        root.render();
    }
    

    ビルド設定を準備

    build.mjs を作成します。

    import * as esbuild from "esbuild";
    import tailwindPlugin from "esbuild-plugin-tailwindcss";
    
    async function build() {
        const commonOptions = {
            bundle: true,
            loader: { ".tsx": "tsx", ".ts": "ts", ".css": "css" },
            jsx: "automatic",
            platform: "browser",
            target: "es2020",
            minify: true,
            sourcemap: true,
            external: ["tailwindcss"],
            plugins: [tailwindPlugin()],
        };
    
        try {
            // Build store-list
            await esbuild.build({
                ...commonOptions,
                entryPoints: ["src/store-list/index.tsx"],
                outfile: "dist/store-list.js",
            });
            console.log("✓ store-list.js built successfully");
    
            console.log("\nBuild completed successfully!");
        } catch (error) {
            console.error("Build failed:", error);
            process.exit(1);
        }
    }
    
    build();
    

    package.json にビルドコマンドを追加します。

    {
      ..
      "scripts": {
        "build": "node build.mjs"
      }
      ..
    

    ビルドして生成物を確認する

    ここまでのファイルを準備できたらビルドしてみましょう。

    // webディレクトリで実行
    npm run build
    

    うまくいけば以下のパスにファイルが生成されています。確認しましょう。

    • web/dist/store-list.css
    • web/dist/store-list.js

    ツールとUIを繋ぐ

    最初に、実装したMCPツールからUIに接続します。

    const fetchStores = { ... };
    
    // ここから追記
    import { readFileSync } from "fs";
    
    const storeListJS = readFileSync("../web/dist/store-list.js", "utf-8");
    const storeListCSS = readFileSync("../web/dist/store-list.css", "utf-8");
    
    const storeListHTML = `
    
    
    
    `;
    
    server.registerResource(
        "store-list-widget",
        "ui://widget/store-list.html",
        {},
        async () => ({
            contents: [
                {
                    uri: "ui://widget/store-list.html",
                    mimeType: "text/html+skybridge",
                    text: storeListHTML,
                    _meta: {
                        "openai/widgetDescription": "Simple Store List",
                    },
                },
            ],
        })
    );
    
    // ...ここまで追記
    
    server.registerTool( ... )
    

    ファイルを変更できたら動作確認してみましょう。

    サーバーを起動します。

    MCP Inspectorを別タブで立ち上げます。

    npx @modelcontextprotocol/inspector
    

    List Resources で、今定義したリソースが読み込まれていればOKです。

    MCP Inspectorの画面。List Resourcesが実行されている
    MCP InspectorでList Resourcesを実行

    ChatGPTで確認する

    ngrokでURLを取得

    それでは実際にChatGPTから試してみましょう。

    ChatGPTから開発中アプリに接続する際、インターネットに公開されたURLが必要です。
    ローカル環境にアクセスするために ngrok を利用します。

    ngrok でアカウントを作成し、手順に従ってセットアップしてください。

    brew install ngrok
    ngrok config add-authtoken 
    

    ポート3000に向けてコマンドを実行します。

    以下のようなURLが吐かれるので、こちらをメモします。

    https://xxxxxxxxxxxxxx.ngrok-free.dev
    

    ChatGPTに設定する

    ChatGPTの「設定 > アプリとコネクター」画面を開き、下部にスクロールして 高度な設定 を選択します。

    ChatGPTのアプリとコネクター設定画面
    高度な設定メニューを開く

    ここから開発者モードをONにしましょう。

    ChatGPTの開発者モード設定画面
    開発者モードをONにする

    アプリとコネクター画面に戻り、右上に「作成する」ボタンが表示されるので選択します。

    ChatGPTのアプリとコネクター画面で、右上に作成ボタンがある

    各項目を入力します。

    ChatGPTのアプリ作成画面で、入力フォームが表示されている
    MCPサーバーURLには先ほどのngrokのものを入力

    名前、説明、MCPサーバーのURL(先ほどコピーしたもの)を記載します。認証は「認証なし」を選択します。
    アイコンはなしでも良いですが、ChatGPTに作ってもらったものを設定してみました。

    ChatGPTで試す

    それでは実際にChatGPTで試してみましょう。

    確実に発火させるために、ここではまずアプリを選択します。

    ChatGPTのチャット画面で、1.+ボタン 2.さらに表示 3.Z Coffee Store と選択
    ChatGPTの+ボタンからアプリを選択

    ChatGPTに尋ねてみましょう。

    「Z Coffee Store の店舗一覧を教えて」
    

    ChatGPTのチャット画面で、UIつきで店舗一覧の情報が表示されている
    店舗一覧がUIつきで表示される

    店舗情報が表示されました!いい感じです。

    ここまでの実装に、住所で絞り込んで店舗を取得するロジックも含まれています。
    「六本木」にある店舗を尋ねてみましょう。

    ChatGPTのチャット画面で、六本木の店舗情報がUIつきで表示されている
    指定した住所で絞り込まれているのが分かる

    絞り込んだ結果が表示されていますね。

    これでアプリの開発は完了です!

    Apps SDKを使ってChatGPTアプリを作る手順を紹介しました。

    今回は最低限のUI実装でしたが、公式のサンプルにはフルスクリーン表示などたくさんの例があり参考になります(そのままだと動かないケースもあるのでIssueを都度確認すると良いです)。

    今年後半には審査の受付が開始されるとアナウンスされています。
    状況をウォッチしながら、色々できることを試していきましょう。

    もし内容に誤りがありましたらご指摘ください🙇‍♂️

    AI開発のTipsなどをポストしてるので、Xをフォローしてもらえるとうれしいです!


    元の記事を確認する

    関連記事