1. Obraz całości: ścieżka wywołania narzędzia przez serwer
Zanim napiszemy kod, ustalmy architekturę. To pomoże nie utonąć w szczegółach.
W terminologii Apps SDK + MCP wszystko wygląda tak: mamy serwer MCP (w naszym kursie to Route Handler app/mcp/route.ts w Next.js), który rejestruje narzędzia i zasoby oraz implementuje handlery dla tych narzędzi.
Schemat wysokiego poziomu:
sequenceDiagram
participant User as Użytkownik
participant Chat as ChatGPT (model)
participant App as ChatGPT App
participant MCP as serwer MCP / backend
participant DB as Katalog/zewnętrzne API
User->>Chat: "Dobierz prezent..."
Chat->>App: decyduje się wywołać tool `suggest_gifts`
App->>MCP: JSON-RPC call_tool (nazwa + argumenty)
MCP->>MCP: Walidacja, autoryzacja
MCP->>DB: Zapytanie do katalogu/filtracja
DB-->>MCP: Lista kandydatów
MCP-->>App: structuredContent + content + _meta
App-->>Chat: Przekazuje wynik modelowi + do widżetu
Chat-->>User: Uzasadnia wybór, pokazuje widżet
Główna myśl: serwer nic nie wie o „magii” modelu. Widzi zwykłe żądanie: nazwa narzędzia + argumenty i ma zwrócić ustrukturyzowaną odpowiedź. Model zaś w ogóle nie widzi waszego kodu, widzi tylko:
- jakie są narzędzia i ich schematy;
- argumenty, które sam wygenerował;
- odpowiedź JSON, którą zwróciliście.
Dlatego naszym zadaniem w tej lekcji jest starannie zaimplementować środkową część: serwer MCP i handlery narzędzi.
Insight: mcp-tools limit
W serwerze MCP liczba narzędzi to taka sama ograniczona metryka jak pamięć czy tokeny kontekstu. Formalnie możecie zarejestrować dziesiątki, a nawet setki tools, ale platforma i model nie pracują z nimi liniowo: każde nowe narzędzie zwiększa „szum” przy routingu.
Praktyka podpowiada orientacyjne wartości:
- twardy limit dla ChatGPT ≈ do 128 MCP-tools na serwer;
- zakres roboczy — do 50 narzędzi. Dalej jakość wyraźnie spada: model zaczyna mylić narzędzia o podobnych opisach, rzadziej pamięta o rzadkich, częściej wybiera nie to, co trzeba.
U Anthropic obraz jest podobny: limit rzędu maksymalnie 100 tools, przy czym sami rekomendują trzymać się okolic do 50.
2. Gdzie żyje logika serwera w szablonie Next.js + Apps SDK
W module 2 uruchomiliśmy już oficjalny szablon Next.js dla ChatGPT App i pobieżnie przeszliśmy jego strukturę. Teraz zobaczymy, gdzie w nim żyje serwer MCP i jak jest połączony z widżetem.
Jeśli korzystacie z tego szablonu, serwer MCP zwykle jest implementowany w pliku app/mcp/route.ts (App Router). Właśnie tam trafiają wywołania JSON‑RPC od ChatGPT: tools/call, resources/list, handshake itd.
Typowa struktura projektu:
my-chatgpt-app/
├─ app/
│ ├─ mcp/
│ │ └─ route.ts # Serwer MCP + rejestracja narzędzi
│ ├─ page.tsx # Widżet React (UI)
│ ├─ layout.tsx # Root layout, Bootstrap SDK
│ └─ globals.css # Style globalne
│
├─ proxy.ts # CORS i inne
├─ next.config.ts
├─ package.json
├─ tsconfig.json
└─ .env
W route.ts:
- tworzymy instancję serwera MCP (przez @modelcontextprotocol/sdk);
- rejestrujemy narzędzia (server.registerTool(...));
- definiujemy handler HTTP, który przyjmuje żądania od ChatGPT i przekazuje je do serwera MCP.
Dalej będziemy pisać kod w TypeScript, opierając się na tej strukturze.
3. Minimalny serwer MCP i handler narzędzia
Zacznijmy od najprostszego: zróbmy serwer i dodajmy nasze szkoleniowe narzędzie suggest_gifts, które zwróci atrapę.
Załóżmy, że MCP‑SDK jest już zainstalowany:
pnpm add @modelcontextprotocol/sdk
I utwórzmy prosty app/mcp/route.ts:
// app/mcp/route.ts
import { NextRequest } from "next/server";
import { McpServer } from "@modelcontextprotocol/sdk/server";
const server = new McpServer({ name: "giftgenius-mcp" });
// Rejestracja narzędzia z minimalnym schematem
server.registerTool(
"suggest_gifts",
{
title: "Dobór prezentów",
description: "Dobiera prezenty według zainteresowań i budżetu.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Krótki opis obdarowywanej osoby." },
},
required: ["query"],
},
},
async ({ input }) => {
// Tutaj będzie logika biznesowa
return {
content: [
{
type: "text",
text: `Atrapa: prezenty dla "${input.query}".`,
},
],
structuredContent: {},
};
}
);
// Handler HTTP Next.js
export async function POST(req: NextRequest) {
const body = await req.text(); // Łańcuch JSON-RPC
const response = await server.handle(body);
return new Response(response, {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
To już działająca wersja: ChatGPT będzie mógł wywołać suggest_gifts, a serwer zwróci tekstową atrapę.
Ważne, że server.registerTool przyjmuje:
- nazwę narzędzia;
- metadane i JSON Schema wejścia;
- handler — funkcję asynchroniczną, do której trafiają argumenty input.
Ale na razie nie ma tu ani walidacji, ani porządnego structured output, ani autoryzacji. Zaraz się tym zajmiemy.
4. Walidacja danych wejściowych i podział warstw
Dlaczego sama JSON Schema nie wystarcza
Tak, platforma sama waliduje podstawowe rzeczy wg schematu: typy pól, wymagane właściwości itp. Ale:
- model może przekazać logicznie niepoprawne dane (np. budżet −100 albo listę zainteresowań z 1000 elementów);
- macie ograniczenia biznesowe (maksymalny budżet, wspierane waluty itp.);
- czasem ChatGPT lub inny klient może zachować się dziwnie i przysłać coś całkiem niespodziewanego.
Dlatego wewnątrz handlera i tak potrzebna jest dodatkowa walidacja.
Podzielmy kod: handler ↔ logika biznesowa
Aby kod serwerowy nie zamienił się w „makaron”, wygodnie trzymać logikę biznesową osobno. Na przykład utwórzmy app/mcp/gifts.ts:
// app/mcp/gifts.ts
export type SuggestGiftsInput = {
age?: number | null;
relationship: "friend" | "partner" | "colleague";
maxBudget: number;
interests: string[];
};
export type GiftItem = {
id: string;
title: string;
price: number;
currency: "USD";
score: number;
tags: string[];
shortDescription: string;
};
// Prosta „baza” prezentów
const CATALOG: GiftItem[] = [
{
id: "board-game-1",
title: "Gra planszowa «Kosmiczna strategia»",
price: 39,
currency: "USD",
score: 0.93,
tags: ["board_games", "strategy", "2-4_players"],
shortDescription: "Świetny prezent dla miłośników gier planszowych.",
},
// ...
];
export function suggestGifts(input: SuggestGiftsInput): GiftItem[] {
if (input.maxBudget <= 0) {
throw new Error("Budżet musi być liczbą dodatnią.");
}
const filtered = CATALOG.filter(
(item) => item.price <= input.maxBudget
);
// Uproszczenie: sortujemy po score i bierzemy top-3
return filtered.sort((a, b) => b.score - a.score).slice(0, 3);
}
Teraz w handlerze narzędzia MCP zajmujemy się:
- parsowaniem input;
- mapowaniem do typu SuggestGiftsInput;
- bezpiecznym wywołaniem suggestGifts;
- opakowaniem wyniku w format zrozumiały dla ChatGPT i naszego UI.
5. Implementacja handlera: od input do structuredContent
Przepiszmy registerTool w route.ts, używając naszej logiki biznesowej:
// app/mcp/route.ts (fragment)
import { suggestGifts, SuggestGiftsInput } from "./gifts";
server.registerTool(
"suggest_gifts",
{
title: "Dobór prezentów",
description:
"Używaj, gdy trzeba dobrać prezenty na podstawie zainteresowań, budżetu i rodzaju relacji.",
inputSchema: {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "Wiek obdarowywanej osoby, jeśli jest znany.",
},
relationship: {
type: "string",
enum: ["friend", "partner", "colleague"],
description: "Rodzaj relacji z obdarowywaną osobą.",
},
maxBudget: {
type: "number",
minimum: 1,
description: "Maksymalny budżet w dolarach amerykańskich.",
},
interests: {
type: "array",
items: { type: "string" },
description: "Zainteresowania obdarowywanego (np. board games, hiking).",
},
},
required: ["relationship", "maxBudget", "interests"],
},
},
async ({ input }) => {
// Podstawowa walidacja logiczna
if (!Array.isArray(input.interests) || input.interests.length === 0) {
return {
isError: true,
content: [
{
type: "text",
text: "Należy podać co najmniej jedno zainteresowanie obdarowywanego.",
},
],
structuredContent: { errorCode: "NO_INTERESTS" },
};
}
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = suggestGifts(payload);
if (items.length === 0) {
return {
content: [
{
type: "text",
text:
"Nie znalazłem odpowiednich prezentów w podanym budżecie. Spróbuj zwiększyć budżet lub zmienić zainteresowania.",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {
content: [
{
type: "text",
text: `Znalazłem ${items.length} pasujących propozycji prezentu.`,
},
],
structuredContent: {
items: items.map((item) => ({
id: item.id,
title: item.title,
price: item.price,
currency: item.currency,
shortDescription: item.shortDescription,
tags: item.tags,
})),
},
};
}
);
Jest tu kilka ważnych momentów.
Po pierwsze, jawnie sprawdzamy, że interests nie jest pustą listą. Nawet jeśli JSON Schema formalnie dopuszcza pustą tablicę, dla nas takie żądanie i tak nie ma sensu. Lepiej od razu zwrócić zrozumiały błąd niż próbować budować przypadkową listę.
Po drugie, zwracamy dwa zbiory danych:
- content — dla modelu. To krótkie tekstowe podsumowanie: „znalazłem N wariantów”. Model użyje tego w swojej odpowiedzi użytkownikowi.
- structuredContent — dla modelu i UI. To już ustrukturyzowany JSON z listą prezentów, który nasz widżet może wyrenderować jako karty.
Częsty błąd — wrzucanie całego JSON-a do content. Tak nie trzeba robić: model marnuje na to tokeny i może się pogubić. Lepiej trzymać content krótki, a szczegóły wkładać do structuredContent.
6. Dodajemy szablon UI i _meta/openai/outputTemplate
Na poziomie Apps SDK serwer mówi też ChatGPT, którego szablonu UI użyć do wizualizacji wyniku narzędzia. Odbywa się to przez zasoby i _meta["openai/outputTemplate"]: serwer rejestruje zasób HTML z mimeType: "text/html+skybridge", a narzędzie w odpowiedzi się do niego odwołuje.
W szablonie Next.js zazwyczaj jest to ukryte pod wygodną otoczką, ale uproszczony zapis wygląda tak:
// gdzieś podczas inicjalizacji serwera MCP
server.registerResource("ui://widget/gifts.html", {
name: "Gift suggestions widget",
mimeType: "text/html+skybridge",
// dalej: sposób zwrócenia HTML (wbudowany szablon lub plik)
});
A w odpowiedzi narzędzia:
return {
content: [{ type: "text", text: `Znalazłem ${items.length} prezentów.` }],
structuredContent: { items: /* ... */ },
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
},
};
Wtedy ChatGPT nie tylko zrozumie strukturę wyniku, ale też podładuje odpowiedni HTML/JS dla widżetu, a nasz komponent React wewnątrz iframe odczyta window.openai.toolOutput i wyrenderuje listę prezentów.
Bardziej szczegółowo o części UI będziemy mówić w lekcjach o przekształceniu ToolOutput → UI (w tym module), więc teraz zwracamy uwagę tylko na powiązanie: handler narzędzia odpowiada nie tylko za dane biznesowe, ale i za to, do jakiego szablonu UI podpiąć wynik. Patrzymy na to oczami serwera MCP: jaki szablon wskazać i co umieścić w structuredContent.
Insight
Twórcy ChatGPT pomyśleli widżet jako szablon do wyświetlania JSON. Dlatego używają nazwy outputTemplate. Chodzi o to, że pierwotny zamysł jest taki: ChatGPT wywołuje mcp-tool, a mcp-tool zwraca JSON i czasem zwraca widżet. Jeśli widżetu nie było, ChatGPT sam decyduje, jak wyświetlić JSON.
A jeśli widżet jest wskazany, ChatGPT pokazuje widżet, przekazuje do widżetu JSON jako toolOutput i widżet powinien wyświetlić JSON. Widżet to szablon do wyświetlania JSON. Właśnie dlatego jest on keszowany już na etapie rejestracji aplikacji w Store.
Możecie używać widżetu tak, jak wam wygodnie: można w nim wywoływać fetch(). Ale jeśli zrozumiecie pierwotny zamysł twórców ChatGPT, łatwiej będzie zaakceptować pewne ograniczenia i — prawdopodobnie — przyszłe zmiany.
7. Autoryzacja i dostęp w handlerze
Dotąd udawaliśmy, że wszystko w świecie to dane publiczne. W praktyce część narzędzi wymaga autoryzacji: dostęp do konta użytkownika, jego zamówień, płatności, dokumentów itd.
W terminologii Apps SDK / MCP narzędziu można ustawić securitySchemes, a następnie, w handlerze, sprawdzać tokeny i kontekst.
Najprostszy przykład:
server.registerTool(
"list_user_orders",
{
title: "Lista zamówień użytkownika",
description: "Zwraca ostatnie zamówienia zalogowanego użytkownika.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
_meta: {
securitySchemes: [{ type: "oauth2", scopes: ["orders.read"] }],
}
},
async ({ auth }) => {
if (!auth?.accessToken) {
return {
isError: true,
content: [
{
type: "text",
text: "Musisz się zalogować, aby zobaczyć zamówienia.",
},
],
_meta: {
// Prosimy ChatGPT o uruchomienie UI OAuth
"mcp/www_authenticate": [
'Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Zaloguj się, aby kontynuować."',
],
},
};
}
// Tutaj sprawdzamy token, issuer, audience, scope...
const orders = await fetchUserOrders(auth.accessToken);
return {
content: [
{
type: "text",
text: `Znalazłem ${orders.length} ostatnich zamówień.`,
},
],
structuredContent: { orders },
};
}
);
Ważne jest, że:
- ChatGPT nie „odgaduje” waszych sprawdzeń. Tylko przekazuje tokeny i kontekst, a wy musicie zrobić normalną autoryzację.
- Specjalne pole _meta["mcp/www_authenticate"] mówi platformie: „trzeba pokazać użytkownikowi UI logowania/odświeżenia tokenu”. Bez tego ChatGPT po prostu zobaczy błąd.
O zawiłościach autoryzacji będziemy mówić osobno w module 10, więc na razie wystarczy podstawowa koncepcja: sprawdzamy token w handlerze, nie wierzymy na słowo modelowi.
8. Integracja z zewnętrznymi API i bazą danych: warstwy i praktyki
Pokusę „zrobić wszystko w handlerze” trudno przezwyciężyć: parsowanie argumentów, zapytanie do bazy, filtracja, mapowanie do structuredContent, logowanie i trochę filozofii — wszystko w jednej funkcji na 150 linii. To jak pisanie całej aplikacji w pages/index.tsx — można, ale boli.
Dużo lepiej rozdzielić warstwy:
// gifts-repository.ts
import type { GiftItem } from "./gifts";
export async function fetchGiftsFromApi(
maxBudget: number,
interests: string[]
): Promise<GiftItem[]> {
const resp = await fetch("https://example.com/api/gifts", {
method: "POST",
body: JSON.stringify({ maxBudget, interests }),
headers: { "Content-Type": "application/json" },
});
if (!resp.ok) {
throw new Error(`Gift API error: ${resp.status}`);
}
const data = (await resp.json()) as GiftItem[];
return data;
}
// gifts.ts (zaktualizowany)
import { fetchGiftsFromApi } from "./gifts-repository";
export async function suggestGifts(input: SuggestGiftsInput): Promise<GiftItem[]> {
if (input.maxBudget <= 0) {
throw new Error("Budżet musi być liczbą dodatnią.");
}
const items = await fetchGiftsFromApi(input.maxBudget, input.interests);
return items.sort((a, b) => b.score - a.score).slice(0, 3);
}
// route.ts (fragment handlera)
async ({ input }) => {
try {
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = await suggestGifts(payload);
// ...
} catch (err) {
console.error("suggest_gifts failed", err);
return {
isError: true,
content: [
{
type: "text",
text: "Wystąpił błąd podczas doboru prezentów. Spróbuj ponownie później.",
},
],
structuredContent: {
errorCode: "INTERNAL_ERROR",
},
};
}
}
Taki podział daje kilka plusów.
- Testowalność: można pisać testy jednostkowe dla suggestGifts i fetchGiftsFromApi bez uruchamiania serwera MCP.
- Czytelność: handler pozostaje cienkim adapterem między protokołem (MCP) a waszą logiką.
- Reużywalność: jeśli później będzie potrzebny ten sam dobór prezentów w innym miejscu (np. w osobnym REST‑API), nie trzeba będzie „wypruwać” logiki z MCP.
9. Logowanie i podstawowa obserwowalność
Implementacja serwerowa narzędzi to świetne miejsce, by od razu zadbać o minimalną obserwowalność. W produkcji będziecie chcieli wiedzieć:
- jakie narzędzia są wywoływane;
- z jakimi argumentami (oczywiście bez PII);
- ile czasu zajmuje obsługa;
- ile jest błędów i jakich.
Ponieważ rozpracowujemy ChatGPT App, profesjonalnych loggerów odłożymy na później. Najprostszy logger‑otoczka wokół handlerów może wyglądać tak:
// simple-logger.ts
export function logToolInvocationStart(tool: string, args: unknown) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_started",
tool,
timestamp: new Date().toISOString(),
// Nigdy nie logujemy PII na produkcji!
args,
})
);
}
export function logToolInvocationEnd(tool: string, ms: number, success: boolean) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_finished",
tool,
durationMs: ms,
success,
timestamp: new Date().toISOString(),
})
);
}
// route.ts (wrapper handlera)
import { logToolInvocationStart, logToolInvocationEnd } from "./simple-logger";
server.registerTool(
"suggest_gifts",
{ /* ...meta... */ },
async ({ input }) => {
const startedAt = Date.now();
logToolInvocationStart("suggest_gifts", {
relationship: input.relationship,
maxBudget: input.maxBudget,
interestsCount: Array.isArray(input.interests)
? input.interests.length
: 0,
});
try {
// ... główna logika ...
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, true);
return result;
} catch (err) {
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, false);
throw err;
}
}
);
W dalszej kolejności, w modułach o metrykach, SLO i monitoringu, na podstawie tych logów będziecie mogli budować wykresy i alerty. Ale nawyk logowania warto wyrobić już teraz.
10. Jak wynik serwera trafia do widżetu (i z powrotem)
W sekcji 6 podpięliśmy już wynik narzędzia do szablonu UI przez _meta["openai/outputTemplate"]. Teraz spójrzmy na to z drugiej strony — jak structuredContent trafia do widżetu React i co z nim zrobić w UI.
Choć ta lekcja skupia się na serwerze, ważne jest, że projektujecie nie tylko „API dla modelu”, ale też „API dla UI”. Serwer zwraca:
- structuredContent — dane widoczne dla modelu i widżetu (przez toolOutput);
- content — „skomprymowany” opis wyniku dla modelu;
- _meta — prywatne pola dla widżetu: openai/outputTemplate, openai/widgetCSP, openai/widgetDomain itd.
Wewnątrz widżetu React robicie potem coś w rodzaju:
// app/page.tsx (fragment)
type ToolOutput = {
items?: {
id: string;
title: string;
price: number;
currency: string;
shortDescription: string;
tags: string[];
}[];
emptyReason?: string;
};
declare global {
interface Window {
openai?: {
toolOutput?: ToolOutput;
};
}
}
export default function GiftWidget() {
const output = typeof window !== "undefined"
? window.openai?.toolOutput
: undefined;
if (!output) {
return <div>Czekam na wyniki doboru prezentów…</div>;
}
if (!output.items || output.items.length === 0) {
return <div>Brak pasujących prezentów. Spróbuj zmienić kryteria.</div>;
}
return (
<ul>
{output.items.map((item) => (
<li key={item.id}>
<strong>{item.title}</strong> — {item.price} {item.currency}
</li>
))}
<ul>
);
}
Właśnie dlatego tak ważne jest, by structuredContent miał stabilny kontrakt i był przyjazny dla UI: osobne pola, a nie zagnieżdżone piekło na 10 poziomach.
Szczegółowo o tej ścieżce mówimy w osobnej lekcji modułu 4, tutaj tylko utrwalamy: serwer i widżet opierają się na tej samej strukturze structuredContent.
11. Obsługa błędów na serwerze: format i strategia
W sekcjach 8–9 już trochę zahaczyliśmy o błędy i logowanie w handlerze. Teraz zbierzmy to w jeden format: jak zwracać błędy narzędzi, by i model, i UI potrafiły z nimi pracować.
Błędy w handlerach są nieuniknione: gdzieś padnie zewnętrzne API, gdzieś przylecą złe dane wejściowe, gdzieś wy osobiście zrobicie literówkę. Najważniejsze — nie zamieniać ich w „500 Internal Server Error bez wyjaśnień” dla modelu i użytkownika.
Dobra implementacja serwerowa narzędzia:
- rozróżnia błędy walidacji użytkownika/modelu i błędy wewnętrzne;
- zwraca jasne pole isError i zrozumiały errorCode w structuredContent;
- daje człowiekowi w content przyjazny komunikat.
Przykład (załóżmy, że metadane narzędzia — title, description, inputSchema itd. — wynieśliśmy już do zmiennej meta, żeby nie dublować ich tutaj):
function makeErrorResult(message: string, code: string) {
return {
isError: true,
content: [
{
type: "text",
text: message,
},
],
structuredContent: {
errorCode: code,
},
};
}
server.registerTool(
"suggest_gifts",
meta,
async ({ input }) => {
try {
if (input.maxBudget > 10000) {
return makeErrorResult(
"Zbyt duży budżet. Doprecyzuj zapytanie (do 10000 USD).",
"BUDGET_TOO_HIGH"
);
}
const items = await suggestGifts({
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
});
if (!items.length) {
return {
content: [
{
type: "text",
text:
"Nie znalazłem prezentów w tym budżecie. Spróbuj zmienić zainteresowania lub zwiększyć budżet.",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {/* normalny wynik */};
} catch (err) {
console.error(err);
return makeErrorResult(
"Wewnętrzny błąd serwera podczas doboru prezentów.",
"INTERNAL_ERROR"
);
}
}
);
Taki format pomaga i modelowi (może spróbować zmienić argumenty), i UI (widżet może pokazywać specyficzne wiadomości dla różnych errorCode).
Szczegółowo o odporności, idempotencji i bezpiecznym projektowaniu narzędzi będziemy mówić za kilka lekcji, ale już teraz warto się przyzwyczaić: lepiej jawnie zwrócić błąd, niż po cichu zrobić coś dziwnego.
Na końcu lekcji zbierzemy to i inne momenty w listę typowych błędów przy implementacji serwerowej narzędzi, aby wygodniej używać jej jako checklisty.
12. Krótki przykład end‑to‑end: od żądania do odpowiedzi
Zbierzmy wszystko, co zrobiliśmy, w logiczny łańcuch na naszej aplikacji GiftGenius.
- Użytkownik pisze do ChatGPT:
„Dobierz prezent dla przyjaciela, lubi gry planszowe, budżet do 50 dolarów”. - Model, znając narzędzie suggest_gifts i jego schemat, decyduje się je wywołać i formuje tool_call:
{ "tool": "suggest_gifts", "arguments": { "relationship": "friend", "maxBudget": 50, "interests": ["board games"], "age": null } } - Platforma wysyła ten JSON‑RPC do naszego serwera MCP (POST /app/mcp), Next.js przekazuje ciało do server.handle(...).
- Nasz handler suggest_gifts:
- waliduje, że interests nie jest puste;
- wywołuje suggestGifts(payload);
- otrzymuje tablicę GiftItem[] (top‑3 wg score);
- pakuje ją do structuredContent.items i dodaje _meta["openai/outputTemplate"] = "ui://widget/gifts.html".
- ChatGPT odbiera odpowiedź, wkłada structuredContent do kontekstu, ładuje zasób HTML widżetu gifts.html, przekazuje tam toolOutput.
- Nasz widżet React odczytuje window.openai.toolOutput.items i renderuje listę prezentów; model na podstawie content i structuredContent pisze użytkownikowi tekstowe wyjaśnienie, dlaczego te prezenty pasują.
- Użytkownik klika np. „Pokaż więcej” w widżecie — widżet wywołuje callTool przez SDK → ponownie trafia do naszego handlera, ale już z innymi argumentami (np. zwiększonym budżetem).
Cały ten łańcuch opiera się na tym, że implementacja serwerowa narzędzia:
- przyjmuje ustrukturyzowany input według uzgodnionej JSON Schema;
- starannie waliduje dane;
- wywołuje odizolowaną logikę biznesową;
- zwraca stabilny structured output;
- w razie potrzeby wskazuje szablon UI i metadane.
13. Typowe błędy przy implementacji serwerowej narzędzi
Błąd nr 1: „Wszystko w jednym miejscu” — gigantyczny handler.
Gdy cała logika i praca z zewnętrznymi API żyją w środku server.registerTool(..., async () => { ... }), kod szybko puchnie i staje się nieczytelnym monolitem. Przy najmniejszej zmianie wszystko się sypie. Lepiej wynieść logikę biznesową do osobnych funkcji/modułów, a handler uczynić cienkim adapterem.
Błąd nr 2: Ślepa wiara w JSON Schema.
Programiści często myślą: „Skoro jest schemat — wejście zawsze będzie poprawne”. Ale model może przysłać dziwne wartości, a zewnętrzni klienci tym bardziej. Nie można polegać tylko na typach i JSON Schema — potrzebna jest walidacja logiczna (granice budżetów, długości tablic, dozwolone wartości itp.).
Błąd nr 3: Wrzucanie wszystkiego do content i ignorowanie structuredContent.
Czasem do content trafia ogromny JSON w postaci łańcucha „na wszelki wypadek”. To zaśmieca podpowiedzi modelu i jest drogie w tokenach, a UI cierpi, bo musi dekodować łańcuch zamiast dostać normalną strukturę. Dużo lepiej trzymać content krótkim, a szczegóły wkładać do structuredContent.
Błąd nr 4: Niestabilny format structured output.
Dziś items to tablica obiektów z polami id, title, price, a jutro nagle zmieniliście price na amount i widżet się wywraca. Albo dodaliście nowy poziom zagnieżdżenia. Tak można robić, ale trzeba albo wersjonować kontrakt, albo ewoluować schemat mniejszymi krokami. Inaczej UI i testy będą się ciągle łamać.
Błąd nr 5: Brak sensownej obsługi błędów.
Rzucić wyjątek i liczyć, że platforma „jakoś to obsłuży” — to słaba strategia. Model zobaczy niezrozumiały błąd JSON‑RPC, użytkownik — czerwoną belkę, a wy stracicie kontekst problemu. Dużo lepiej zwracać jawne isError, errorCode i zrozumiały komunikat, a szczegóły logować po stronie serwera.
Błąd nr 6: Ignorowanie autoryzacji i zaufanie do modelu.
Czasem programiści myślą: „Model jest mądry, nie wywoła tego narzędzia, jeśli użytkownik nie jest zalogowany”. W rzeczywistości model nie zna waszych ACL i limitów, widzi tylko opisy narzędzi. Wszystkie sprawdzenia uprawnień muszą być w serwerowym handlerze, niezależnie od opisu narzędzia.
Błąd nr 7: Logowanie wszystkiego jak leci, łącznie z PII.
Bardzo łatwo z przyzwyczajenia zalogować cały input. W przypadku ChatGPT App może to zawierać PII (imiona, e‑mail, adresy itp.), co narusza i politykę OpenAI, i zdrowy rozsądek. Lepiej logować wyłącznie informacje zagregowane/zdeidentyfikowane: typ relacji, zakres budżetu, liczba zainteresowań.
Błąd nr 8: Brak timeoutów i retry przy pracy z zewnętrznymi API.
Jeśli narzędzie w handlerze robi fetch do zewnętrznego API bez timeoutów i powtórzeń, każde opóźnienie tego API będzie wyglądało jak „ChatGPT się zawiesił”. Użytkownik pomyśli, że padła cała aplikacja. Po stronie serwera trzeba ustawiać ograniczenia czasowe, obsługiwać timeouty i zwracać sensowny błąd.
GO TO FULL VERSION