CodeGym /Kursy /ChatGPT Apps /Jak działa szablon: struktura projektu i kluczowe pliki

Jak działa szablon: struktura projektu i kluczowe pliki

ChatGPT Apps
Poziom 2 , Lekcja 1
Dostępny

1. Wprowadzenie

Projekt ChatGPT App HelloWorld — to nie „magiczna czarna skrzynka od CodeGym, w której lepiej nic nie ruszać”. To zwykły projekt Next.js, w którym jednocześnie żyją:

  • frontend renderowany wewnątrz ChatGPT,
  • serwer MCP, który odpowiada na wywołania narzędzi (tools),
  • ustawienia, które sklejają to wszystko z ChatGPT.

Jeśli nie wiesz, gdzie co leży, zwykle pojawiają się trzy klasyczne scenariusze:

  1. Programista przypadkowo używa window w pliku serwerowym, łapie crash i zaczyna nienawidzić cały stos.
  2. Próbuje dodać przycisk w UI, ale edytuje nie ten page.tsx (np. korzeń aplikacji zamiast widżetu) i nie widzi zmian w ChatGPT.
  3. Przypadkowo umieszcza OPENAI_API_KEY po stronie klienta i klucz wycieka do przeglądarki.

Dlatego dzisiejszy cel — zaznaczyć mapę: gdzie jest UI, gdzie MCP, gdzie konfiguracje i dokąd iść, gdy chcesz:

  • zmienić wygląd widżetu;
  • dodać nowe narzędzie (tool);
  • podkręcić jakąś ustawę platformową (CORS, assetPrefix itp.).

2. Wysokopoziomowa anatomia projektu

Projekt Next.js ChatGPT App HelloWorld używa App Router i jest zorganizowany wokół folderu app/. W nim, w jednym drzewie stron, współistnieją:

  • UI widżetu, który będzie renderowany wewnątrz ChatGPT,
  • endpoint MCP, który będzie obsługiwał wywołania narzędzi.

Typowe drzewo (uproszczone; nazwy folderów w twoim szablonie mogą się różnić, ale wzorzec jest ten sam):

my-chatgpt-app/
├─ app/
│  ├─ api/                          // REST API
│  │  └─ time/                      // GET /api/time zwraca czas na serwerze
│  │     └─ route.ts
│  ├─ hooks/                        // Zestaw hooków z oficjalnego Apps SDK
│  │  ├─ use-call-tool.ts
│  │  ├─ use-display-mode.ts
│  │  └─ use-open-external.ts
│  ├─ mcp/                          // Serwer MCP: tu ChatGPT puka, gdy wywołuje tools
│  │  └─ route.ts
│  ├─ globals.css                   // Główny globals.css całej aplikacji
│  ├─ layout.tsx                    // Główny layout całej aplikacji
│  └─ page.tsx                      // Strona widżetu wewnątrz ChatGPT
├─ public/                          // Zasoby statyczne: ikony, manifest itp.
├─ next.config.ts                   // Konfig Next.js i ustawienia specyficzne dla Apps (assetPrefix itp.)
├─ proxy.ts                         // CORS/nagłówki do pracy wewnątrz iframe (dawny middleware.ts)
├─ package.json                     // Zależności projektu
├─ tsconfig.json                    // Konfiguracja TypeScript
└─ .env.local                       // Sekrety: OPENAI_API_KEY itd.

Jeśli widżetów jest kilka, zwykle kładzie się je nie w app/page.tsx, tylko w app/widget/page.tsx. Ale logika się nie zmienia: wciąż jest jedna strona‑widżet i jeden endpoint pełniący rolę serwera MCP.

Wygodnie myśleć tak: twój repozytorium — to „dwuobliczny Janus”:

  • jedno „oblicze” — ścieżka /mcp, dokąd idzie ChatGPT, gdy chce wywołać narzędzie;
  • drugie „oblicze” — ścieżka /widget (albo /), która ładuje się w iframe, gdy model decyduje się pokazać twój UI.

Aby się nie gubić, utrwalmy w głowie trzy grupy plików:

  1. Warstwa UI — wszystko, co związane ze stronami React/Next (app/widget, komponenty, style).
  2. Warstwa MCPapp/mcp/route.ts i pliki, z których korzysta.
  3. Warstwa „klejąca” i konfiguracjenext.config.ts, proxy.ts, .env.local, package.json, tsconfig.json.

Nieco niżej omówimy każdą z tych warstw.

3. Gdzie żyje widżet: folder app/widget i/lub app/page.tsx

Zacznijmy od tego, czego będziesz dotykać najczęściej — widżetu, czyli UI widocznego wewnątrz ChatGPT.

W większości aktualnych projektów istnieje albo:

  • folder app/widget/page.tsx — widżet żyje pod osobnym prefiksem /widget,
  • albo korzeń app/page.tsx — widżet pokrywa się ze stroną główną.

Główne cechy pliku z widżetem:

  • na samej górze jest 'use client', ponieważ komponent działa w przeglądarce, komunikuje się z window i Apps SDK;
  • to zwykły komponent React, który renderuje znacznik i (nieco później w kursie) rozmawia z window.openai.

Najprostszy przykład edukacyjnego widżetu (bardzo podobny możesz już zobaczyć u siebie w projekcie):

// app/widget/page.tsx
'use client';

import React from 'react';

export default function WidgetPage() {
  return (
    <main className="p-4">
      <h1 className="text-xl font-semibold">
        HelloWorld — ChatGPT App
      </h1>
      <p className="text-sm text-gray-500">
        Tutaj będziemy budować interfejs (UI) naszego widżetu.
      </p>
    </main>
  );
}

Jeśli w twoim szablonie widżet leży bezpośrednio w app/page.tsx, kod będzie niemal taki sam, tylko bez pośredniego folderu widget.

Zwróć uwagę na kilka kwestii.

Po pierwsze, dyrektywa 'use client' jest obowiązkowa: widżet czyta/pisze do window.openai, słucha zdarzeń itp., a to możliwe tylko w komponencie klienckim. Jeśli ją usuniesz, Next spróbuje zrobić stronę serwerową i dostaniesz błędy w stylu „window is not defined”.

Po drugie, to zwykły, wcale nie magiczny komponent React. Możesz:

  • dzielić go na podkomponenty w components/,
  • używać Tailwind albo dowolnego innego systemu CSS,
  • podłączać konteksty, hooki itd.

Po trzecie, później właśnie tutaj będziesz:

  • czytać window.openai.toolInput oraz window.openai.toolOutput, aby renderować prawdziwe dane,
  • zapisywać widgetState poprzez window.openai.setWidgetState,
  • wywoływać openExternal, callTool i inne metody runtime’u.

Na razie wystarczy wiedzieć: jeśli chcesz zmienić interfejs wizualny — niemal na pewno trafisz do app/widget/page.tsx lub app/page.tsx.

4. Główny layout: app/layout.tsx jako „ramka” całej aplikacji

Kolejny ważny plik to app/layout.tsx. On:

  • definiuje strukturę HTML (<html>, <body>),
  • podłącza globalne style (globals.css),
  • często inicjuje „bootstrap” dla Apps SDK (otoczkę, która słucha window.openai i przekazuje dane do Reacta).

Uproszczony przykład:

// app/layout.tsx
import './globals.css';
import type { ReactNode } from 'react';
import { OpenAIAppProvider } from '@/lib/openai-app-provider';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <NextChatSDKBootstrap baseUrl={baseURL} />
      </head>
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-hidden`}>
        {children}
      </body>
    </html>
  );
}

Nazwa NextChatSDKBootstrap jest tu umowna, w twoim szablonie może to być OpenAIAppProvider albo inny komponent. Jego zadanie jest zwykle jedno: skonfigurować połączenie między drzewem React a runtime’em Apps SDK, zasubskrybować globalne dane (theme, displayMode, toolInput itd.) i rozdać je dzieciom.

Ważny wniosek praktyczny: jeśli potrzebujesz podłączyć globalny kontekst, style lub bibliotekę UI (np. shadcn/ui) — miejsce na to to niemal zawsze app/layout.tsx (albo layout wewnątrz app/widget dla ustawień i komponentów specyficznych właśnie dla widżetu).

Omówienie NextChatSDKBootstrap

NextChatSDKBootstrap podpatrzyłem w oficjalnym szablonie od Vercel. Jeśli nie wiedziałeś, to właśnie ci goście stworzyli i rozwijają Next. Na ich stronie jest dobry wpis o ChatGPT App na Next. Jest też Starter Template. Choć w kilku miejscach jest już trochę przestarzały, są duże szanse, że będą dbać o jego aktualność.

Wyróżnijmy 5 kluczowych rzeczy, które daje NextChatSDKBootstrap:

  • 1. Naprawia problemy z hydratacją
    Chodzi o to, że ChatGPT najpierw ładuje HTML twojego widżetu na swój serwer, czyści go i łatkuje. W efekcie mechanizm hydratacji narzeka i sypie ostrzeżeniami w konsoli. To może utrudnić przejście review.
  • 2. Łata historię przeglądarki
    Twój widżet ładowany jest w iframe ze specjalnej domeny w ChatGPT. Jeśli będziesz używać własnej domeny, zepsujesz piaskownicę. Dlatego w historii przeglądarki zapisywana jest tylko ścieżka bez domeny.
  • 3. Nadpisuje funkcję fetch()
    Każdy twój fetch() na adresy względne bez domeny nie zadziała w widżecie, bo domena w iframe jest inna. Podmieniamy więc funkcję fetch() na własną, która wysyła żądania bez domeny pod właściwy URL. Jeśli domena jest podana, wszystko działa bez zmian.
  • 4. Działa klikanie linków
    Jeśli linki otwierałyby się wewnątrz iframe, ChatGPT tego nie zaakceptuje. Dlatego dodano kod, który nasłuchuje kliknięć w linki i otwiera je w zewnętrznym oknie przez openExternal().
  • 5. Ustawienie head base (DEPRECATED)
    Ten kod dodawał też <base> w <head>, ale to już nie działa. Piaskownica resetuje każdy ustawiony base, więc polecam używać absolutnych linków do wszystkiego: skrypty, zasoby, fonty, API itp.

5. Serwer MCP: app/mcp/route.ts

Przechodzimy teraz do drugiej połowy „dwuoblicznego Janusa” — serwera, który rozmawia z ChatGPT przez MCP.

Plik app/mcp/route.ts to zwykły Route Handler App Routera, który:

  • przyjmuje żądania HTTP od ChatGPT (zwykle POST z payloadem JSON w formacie MCP),
  • przekazuje je do serwera MCP (na bazie @modelcontextprotocol/sdk albo cienkiej otoczki),
  • zwraca z powrotem odpowiedź JSON w formacie MCP.

Są dwie opcje: można pisać na gołym MCP SDK, albo spróbować wygładzić kanty i użyć kilku klas od Next/Vercel.

Oto wariant na czystym TS MCP SDK:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

// 1. Tworzymy serwer MCP
const server = new McpServer({
  name: "simple-mcp-server",
  version: "1.0.0",
});

// 2. Rejestrujemy MCP Resources
// 3. Rejestrujemy MCP Tools

// 4. Transport HTTP
const transport = new HttpServerTransport({
  port: 3001,
  path: "/mcp",
});

// 5. Start serwera
await server.connect(transport);

Ale lepiej użyć kilku gotowych klas, aby pracowało się przyjemniej:

// app/mcp/route.ts
import { NextRequest } from 'next/server';
import { createMcpHandler } from "mcp-handler";

const handler = createMcpHandler(async (server) => {
  const gateway = new McpGateway(server);
  await gateway.initialize();
  gateway.registerResources();
  gateway.registerTools();
});

export const GET = handler;
export const POST = handler;

Tutaj McpGateway to klasa‑otoczka wokół McpServer, którą gdzieś tworzysz (np. w lib/mcp/server.ts) przy pomocy SDK. W naszym przypadku całość mieści się w app/mcp/route.ts. Przeanalizujmy dokładnie, co znajduje się w tym pliku.

type ContentWidget

Na początku pliku opisany jest typ ContentWidget. Zawiera wszystkie dane widżetu i używany jest w dwóch miejscach: przy rejestracji widżetu jako mcp‑resource oraz gdy mcp‑tool zwraca metadata, gdzie wskazuje, którego widżetu użyć do wyświetlenia danych, które zwrócił.

type ContentWidget = {
  id: string;            // Unikalna nazwa/klucz
  title: string;         // Title
  description: string;   // Description
  templateUri: string;   // Unikalny URI widżetu, może być dowolny. Na nic nie wpływa.
  invoking: string;      // Napis nad widżetem podczas ładowania
  invoked: string;       // Napis nad widżetem po załadowaniu
  html: string;          // Cały kod HTML widżetu.
  widgetDomain: string;  // „Domena” widżetu. Na nic nie wpływa.
};

class McpGateway

Klasa‑otoczka nad McpServer, upraszcza pewne rzeczy. Zawiera 6 metod:

  • initialize() — tutaj ładujemy HTML naszego widżetu
  • registerResources() — rejestrujemy widżety jako mcp‑resources
  • registerTools() — rejestrujemy funkcje jako mcp‑tools
  • widgetMeta() — zwraca metadane widżetu
  • getAppsSdkCompatibleHtml() — ładuje kod HTML widżetu i lekko go łata
  • makeImgUrlsAbsolute() — łata HTML: zamienia linki do obrazków na absolutne

Przejdźmy przez nie dokładniej:

public async initialize()

Ta metoda ładuje z internetu kod HTML widżetów i wypełnia obiekt typu ContentWidget.

{
  id: "hello_world",                         // Unikalny klucz widżetu
  templateUri: "ui://widget/hello_world.html", // Unikalny URI widżetu. „ui:” nic nie znaczy.
  title: "HelloWorld Widget",               // Nazwa widżetu
  description: "Displays the HelloWorld widget", // Wyjaśnienie dla LLM, co robi widżet
  invoking: "Loading widget...",            // Napis nad widżetem podczas jego ładowania
  invoked: "Widget loaded",                 // Napis nad widżetem po jego załadowaniu
  html: htmlWidget,                         // HTML widżetu
  widgetDomain: baseURL,                    // „Domena” widżetu. Obecnie na nic nie wpływa.
}

public registerResources()

Rejestruje widżety jako mcp‑resources. Wywołuje metodę server.registerResource(), do której przekazywane są 4 parametry:

  • id/klucz zasobu MCP
  • URI zasobu (potrzebne konkretnie dla protokołu MCP; dla widżetu to w praktyce synonim unikalnego adresu)
  • Metadane zasobu MCP
  • Funkcja zwracająca zasób MCP

Metadane widżetu

{
  title: widget.title,                 // Nazwa zasobu/widżetu
  description: widget.description,     // Opis zasobu/widżetu
  mimeType: "text/html+skybridge",     // Ważne! Tylko taki HTML będzie renderowany jako widżet
  _meta: {
    "openai/widgetDescription": widget.description, // Opis widżetu
    "openai/widgetPrefersBorder": true,            // Prosimy ChatGPT o obramowanie widżetu
  },
}

Widżet jako zasób MCP

{
  uri: uri.href,                        // Nasz URI (brany z parametru uri)
  mimeType: "text/html+skybridge",      // Ważne! Tylko taki HTML będzie renderowany jako widżet
  text: widget.html,                    // HTML widżetu
  _meta: {
    "openai/widgetDescription": widget.description, // Opis widżetu
    "openai/widgetPrefersBorder": true,            // Prosimy ChatGPT o obramowanie widżetu
    "openai/widgetDomain": widget.widgetDomain,    // „Domena” widżetu. Obecnie na nic nie wpływa.
    "openai/widgetCSP": {                          // Ważne! Domena dostępna widżetowi:
      connect_domains: [                           // Domeny do połączeń (fetch itp.)
        baseURL,
        "https://codegym.cc",
      ],
      resource_domains: [                          // Domeny do zasobów (css/fonts/img)
        baseURL,
        "https://codegym.cc",
        "https://cdn.tailwindcss.com",
        "https://persistent.oaistatic.com",
        "https://fonts.googleapis.com",
        "https://fonts.gstatic.com"
      ]
    }
  },
}

W przyszłości jeszcze nie raz wrócimy do openai/widgetCSP, ale teraz chciałbym podkreślić 2 kwestie:

  • connect_domains — lista domen dla:
    • fetch()
    • ładowania skryptów
    • openExternal()
  • resource_domains — lista domen dla:
    • obrazków
    • CSS
    • fontów

Teoretycznie możesz wpisać 200 domen, ale czy z taką listą przejdziesz review — to już inna sprawa.

Przejrzałem też te parametry w już opublikowanych aplikacjach i znalazłem tam amplitude.com. To też dobra wiadomość. Myślę, że porządna analityka nikomu nie zaszkodzi.

public registerTools()

Rejestruje funkcje jako mcp‑tools. Wywołuje metodę server.registerTool(), do której przekazywane są 3 parametry:

  • id/klucz narzędzia MCP
  • Metadane narzędzia MCP
  • Funkcja, która zwraca narzędzie MCP

Metadane narzędzia

Wszystkie parametry na tej liście są ważne. Szczegóły omówię w kolejnych wykładach.

{
  title: widget.title,                               // Nazwa narzędzia
  description: "Returns HelloWorld widget",          // Ważne! Opis tego, co robi narzędzie
  inputSchema: z.object({}).describe("No inputs"),   // Schemat parametrów narzędzia. Może być Zod
  _meta: this.widgetMeta(widget),                    // Metadane widżetu: który widżet wyświetlić
  annotations: {
    destructiveHint: false,                          // Metoda robi coś istotnego — potrzebny confirm
    openWorldHint: false,                            // Metoda zmienia coś w usługach zewnętrznych
    readOnlyHint: true                               // Metoda nic nie zmienia
  },
}

Funkcja, która robi coś ważnego

async (input, extra) => {
  // 1. Walidacja parametrów
  // 2. Robimy coś ważnego
  return {
    content: [{ type: "text", text: "HelloWorld MCP-tool" }], // Opis wyniku dla AI
    structuredContent: {                                      // Ważne! To jest JSON wyniku.
      timestamp: new Date().toISOString()                     // Może zawierać dowolne dane.
    },
    _meta: this.widgetMeta(widget),                           // Metadane widżetu, który wyświetla JSON
  };                                                          // Może nie występować — wtedy widżetu nie będzie
}

private widgetMeta(widget: ContentWidget)

Zwraca metadane widżetu — na ich podstawie ChatGPT określi, którego widżetu użyć do wyświetlenia wyniku JSON.

{
  "openai/outputTemplate": widget.templateUri,            // URI widżetu
  "openai/toolInvocation/invoking": widget.invoking,      // Napis nad widżetem podczas ładowania
  "openai/toolInvocation/invoked": widget.invoked,        // Napis nad widżetem po załadowaniu
  "openai/widgetAccessible": true,                        // MCP‑tool można wywoływać z widżetu
  "openai/resultCanProduceWidget": true,                  // MCP‑tool zwróci widżet
}

Osobno chciałbym omówić tak prostą rzecz jak "openai/outputTemplate". W protokole MCP są 3 byty (o których więcej dowiesz się w module 6):

  • MCP Resources
  • MCP Templates
  • MCP Tools

Otóż ten "openai/outputTemplate" nie ma żadnego związku z MCP Templates. MCP Templates w ogóle nie są używane w ChatGPT Apps. Słowo template jest tu z tego powodu:

Widżety wymyślono jako szablon do wyświetlania JSON. MCP‑tool zwraca jakiś JSON, AI wyświetla widżet, przekazuje mu JSON przez parametr ToolOutput, a widżet ładnie prezentuje ten JSON. outputTemplate — to po prostu synonim widżetu.

Myślę, że na tym zakończymy. Szczegóły omówimy w module 4: jak dokładnie opisywać narzędzia, JSON Schema i handler’y. Teraz wystarczy rozumieć: jeśli coś jest związane z narzędziami (tools) i logiką — szukaj w pobliżu app/mcp/route.ts.

6. Konfiguracja i „klej”: next.config.ts, middleware.ts, .env i spółka

Teraz rozbierzemy główny zestaw plików potrzebnych do tego, by twój projekt Next.js działał poprawnie wewnątrz iframe ChatGPT i był dostępny dla ChatGPT przez tunel HTTPS (ngrok, Cloudflare Tunnel itp.; o tunelach porozmawiamy jeszcze osobno).

next.config.ts

W tym pliku, poza standardowymi ustawieniami Next.js, często konfiguruje się:

  • assetPrefix — aby statyka (JS, CSS z /_next/) poprawnie ładowała się nie z domeny ChatGPT, tylko z twojego adresu deweloperskiego (tunelu lub Vercel);
  • inne ustawienia specyficzne dla szablonu (np. flagi eksperymentalne pod Next 16).

W praktyce wygląda to jak zwykły eksport nextConfig z potrzebnymi polami. Dla wykładu ważne jest jedno: jeśli w ChatGPT widżet nie może załadować CSS/JS, bardzo często winowajcą jest assetPrefix.

proxy.ts (dawny middleware.ts)

Ten plik wstawia warstwę middleware między żądaniem z ChatGPT a twoimi trasami. W szablonie zwykle:

  • ustawia nagłówki CORS, aby iframe ChatGPT w ogóle miał prawo odwoływać się do twojego serwera;
  • czasem konfiguruje dodatkowe nagłówki dla React Server Components.

Nie musisz teraz znać wszystkich niuansów. Warto tylko pamiętać: jeśli ChatGPT skarży się na CORS albo widzisz dziwne błędy w DevTools o zakazie dostępu, zajrzyj do proxy.ts.

.env

Plik .env (lub .env.local) — miejsce na sekrety i parametry środowiskowe:

  • OPENAI_API_KEY (jeśli serwer MCP sam woła OpenAI API),
  • adresy twoich wewnętrznych API,
  • tokeny usług zewnętrznych itd.

Jest ważny niuans: w Next.js zmienne zaczynające się od NEXT_PUBLIC_ automatycznie trafiają do bundla JS i stają się dostępne w przeglądarce. Nigdy nie rób tak z OPENAI_API_KEY; sekrety muszą być wyłącznie zmiennymi serwerowymi.

package.json i tsconfig.json

W package.json zobaczysz:

  • wersje Next.js, React, Apps SDK, MCP SDK i innych zależności;
  • skrypty dev, build, start, a czasem także pomocnicze komendy (linter, formatter itd.).

W tsconfig.json leżą dobrze znane ustawienia TypeScript:

  • ścieżki aliasów (@/lib, @/components),
  • tryb ścisły,
  • targety kompilacji.

Z punktu widzenia tego kursu najważniejsze jest rozumieć, że szablon używa zwykłego stosu TypeScript i możesz go rozszerzać w standardowy sposób.

7. Szybki „nawigator po projekcie” dla developera

Ustalmy, dokąd iść, gdy chcesz zrobić typowe rzeczy. Bez list, po prostu w formie mini‑scenariuszy.

Jeśli chcesz zmienić tekst/przyciski w widżecie, otwierasz plik z UI widżetu: to albo app/widget/page.tsx, albo app/page.tsx — w zależności od szablonu. Tam edytujesz JSX, dodajesz nowe komponenty, podłączasz system designu. I właśnie tutaj będziesz używać runtime’u Apps SDK (window.openai lub wygodnych hooków) do wyświetlania danych.

Jeśli trzeba dodać nowy przycisk, który coś robi po stronie serwera, i tak zaczynasz od pliku UI. Przycisk w widżecie po kliknięciu wywoła window.openai.callTool, a implementację tego narzędzia dodasz w konfiguracji serwera MCP, czyli w kodzie obok app/mcp/route.ts. Powiązanie UI ↔ logika tooli będziemy rozbierać w modułach 4 i dalszych.

Gdy chcesz nauczyć ChatGPT nowej funkcjonalności (np. „wyszukiwania wycieczek” albo „doboru produktów”), idziesz do warstwy MCP (pliki importowane z app/mcp/route.ts). Tam rejestrujesz nowe narzędzie z JSON Schema, opisem i handlerem. Widżet może potem czytać wynik przez window.openai.toolOutput i ładnie go wyświetlać.

Jeśli posypała się statyka albo widżet dziwnie wygląda tylko w ChatGPT, a lokalnie wszystko gra, przypomnij sobie o warstwie „klejącej”. W pierwszej kolejności sprawdź next.config.ts (szczególnie assetPrefix) oraz middleware.ts/proxy.ts (CORS). Jeśli niedawno zmieniałeś tunel, URL albo wdrażałeś na Vercel, to poprawność tych ustawień jest kluczowa.

Wreszcie, jeśli podejrzewasz problemy z kluczami albo środowiskiem, twoja trójka plików to — .env.local, package.json (aby upewnić się, jakie zależności i skrypty faktycznie są używane) i logi serwera developerskiego. To właśnie ten zestaw odpowiada za to, by MCP miał dostęp do potrzebnych sekretów i usług.

8. Mini‑praktyka: poznajemy system plików „ręcznie”

Teoria teorią, ale utrwalmy ręcznie, gdzie co leży. Kroki poniżej możesz wykonać od razu w edytorze/IDE.

Spróbuj otworzyć w swoim projekcie folder app i znaleźć, który plik odpowiada za widżet. Jeśli szablon używa app/page.tsx, właśnie tam zobaczysz znajomy napis w rodzaju „HelloWorld — ChatGPT App” lub tekst powitalny. Jeśli nie ma osobnego folderu widżetu, otwórz app/page.tsx i upewnij się, że jest tam 'use client' i jakaś struktura JSX.

Następnie znajdź app/mcp/route.ts. Zwróć uwagę, jakie moduły importuje: zwykle zobaczysz albo bezpośrednie użycie MCP SDK, albo wywołanie funkcji pomocniczej z lib/mcp/*. Oceń, na ile „cienka” jest ta warstwa — w idealnym przypadku prawie nie ma tam logiki biznesowej, tylko „przyjął JSON → przekazał serwerowi → zwrócił JSON”.

Potem zajrzyj do next.config.ts oraz proxy.ts/middleware.ts. Nie musisz rozumieć wszystkiego, co tam jest — po prostu zanotuj, że:

  • next.config.ts odpowiada za konfigurację Next, w tym zasady budowania i serwowania assetów;
  • proxy.ts ingeruje w żądania HTTP (niemal na pewno zobaczysz tam pracę na nagłówkach).

Na koniec otwórz .env lub .env.local i upewnij się, że twoje klucze leżą właśnie tam, a nie w kodzie. Jeśli gdzieś zobaczysz NEXT_PUBLIC_OPENAI_API_KEY — to świetny moment, by to poprawić, póki mowa tylko o lokalnym developmencie.

9. Schemat wizualny: jak ChatGPT wchodzi w interakcję z twoim szablonem

Aby obraz był kompletny, warto spojrzeć na prosty przepływ:

flowchart TD
    U[Użytkownik w ChatGPT] -->|Wpisuje zapytanie| M[Model ChatGPT]

    M -->|Wywołuje tool| MCP["Twój endpoint MCP
app/mcp/route.ts"] MCP -->|"Odpowiedź JSON MCP (structuredContent, _meta, link do UI)"| M M -->|Decyduje, aby pokazać UI| WIDGET_URL["URL widżetu
(/widget lub /)"] WIDGET_URL -->|iframe| W[Twój widżet
app/page.tsx] W -->|czyta window.openai.toolOutput
+ widgetState| U

Warto zauważyć, że inicjatorem jest niemal zawsze model ChatGPT, a nie przeglądarka użytkownika, jak w klasycznej aplikacji webowej. Twój app/mcp/route.ts i app/widget/page.tsx — to po prostu dwoje różnych „drzwi” do tego samego projektu Next.js: jedne dla robota (MCP), drugie dla UI.

Jeśli będziesz mieć w głowie tę mapę projektu (widżet → warstwa MCP → konfiguracje) i świadomie unikać wymienionych min, dalej w kursie skupisz się już na logice i UX swojego App, a nie na szukaniu „tego jednego pliku, który wszystko psuje”.

10. Typowe błędy przy pracy ze strukturą szablonu

Błąd nr 1: Mylenie widżetu ze zwykłą stroną serwisu.
Czasem developer widzi w szablonie i app/page.tsx, i app/widget/page.tsx, edytuje „nie ten” plik i dziwi się, czemu zmiany nie pojawiają się w ChatGPT. Widżet — to ta strona, która używana jest jako outputTemplate/iframe dla narzędzia MCP. Jeśli zmieniasz inny rout, ChatGPT nawet się o tym nie dowie. Zawsze sprawdzaj README szablonu i patrz, jaki URL wskazano jako widżet.

Błąd nr 2: Pisanie kodu klienckiego (window, document) w serwerowych plikach MCP.
Plik app/mcp/route.ts i wszystko, co importuje, wykonuje się na serwerze. Każda próba użycia tam window lub DOM‑API skończy się crashem runtime’u. Jeśli chcesz zrobić coś w UI, to prawie na pewno powinno być w plikach pod app/widget albo innych komponentach klienckich. Warstwa MCP — to czysty backend: żądania, bazy, zewnętrzne API i formowanie ustrukturyzowanej odpowiedzi.

Błąd nr 3: Ignorowanie assetPrefix i ustawień CORS.
Lokalnie na localhost:3000 wszystko działa świetnie, ale gdy otworzysz App przez tunel w ChatGPT — style znikają, JS się nie ładuje, a w konsoli jest mnóstwo błędów CORS. Często powodem jest to, że konfiguracja next.config.ts lub middleware.ts/proxy.ts nie uwzględnia nowego publicznego URL albo została przypadkowo popsuta przy refaktorze. Zmieniając te pliki, pamiętaj, że twój kod będzie żył wewnątrz iframe na domenie ChatGPT, a nie bezpośrednio na localhost.

Błąd nr 4: Trzymanie sekretów nie w .env, tylko w kodzie albo w zmiennych NEXT_PUBLIC_*.
Ukrywanie OPENAI_API_KEY w const apiKey = 'sk-...' gdzieś w app/widget/page.tsx — to najgorszy pomysł: klucz trafi do bundla JS i wycieknie do każdego użytkownika. Prawie tak samo źle — zrobić zmienną NEXT_PUBLIC_OPENAI_API_KEY, bo prefiks NEXT_PUBLIC_ gwarantuje trafienie do przeglądarki. Zawsze kładź sekrety do .env bez tego prefiksu i używaj ich tylko po stronie serwera (serwer MCP, funkcje backendowe).

Błąd nr 5: Uznawanie szablonu za „zbyt mądry” i strach przed jego dotknięciem.
Czasem developerzy traktują oficjalny starter jak coś świętego: „lepiej tam nie zaglądać, bo może zepsuję integrację”. W rezultacie piszą cały swój kod gdzieś obok, komplikują architekturę i i tak wpadają na te same miny. W rzeczywistości szablon — to po prostu starannie złożony projekt Next.js z kilkoma ustawieniami pod Apps SDK. Zrozumienie, że app/ — to UI i MCP, a reszta — zwykłe konfiguracje, bardzo uwalnia: zaczynasz pracować z kodem jak z typowym projektem React/Next, a nie magiczną skrzynką.

Błąd nr 6: Próba rozwiązania wszystkich problemów „na poziomie widżetu”.
Czasem kusi, by wszystko robić w UI: i logikę biznesową, i dostęp do baz, i zewnętrzne zapytania do API. W kontekście ChatGPT Apps to szczególnie zły pomysł: widżet żyje w bardzo twardej piaskownicy, nie widzi twoich sekretów i mocno zależy od window.openai. Jeśli trzeba zrobić coś poważnego — miejsce jest w warstwie MCP i usługach backendowych, a widżet powinien być cienką warstwą prezentacji, która wyświetla ustrukturyzowane dane i, w razie potrzeby, wyzwala narzędzia.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION