CodeGym /Kursy /ChatGPT Apps /Implementacja serwerowa narzędzi: od wywołania do odpowie...

Implementacja serwerowa narzędzi: od wywołania do odpowiedzi

ChatGPT Apps
Poziom 4 , Lekcja 2
Dostępny

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 roboczydo 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:

  1. tworzymy instancję serwera MCP (przez @modelcontextprotocol/sdk);
  2. rejestrujemy narzędzia (server.registerTool(...));
  3. 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.

  1. Użytkownik pisze do ChatGPT:
    „Dobierz prezent dla przyjaciela, lubi gry planszowe, budżet do 50 dolarów”.
  2. 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
      }
    }
    
  3. Platforma wysyła ten JSON‑RPC do naszego serwera MCP (POST /app/mcp), Next.js przekazuje ciało do server.handle(...).
  4. 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".
  5. ChatGPT odbiera odpowiedź, wkłada structuredContent do kontekstu, ładuje zasób HTML widżetu gifts.html, przekazuje tam toolOutput.
  6. 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ą.
  7. 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.

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