1. Co dziś zbudujemy i jak to wpisuje się w aplikację
Przypomnijmy nasze aplikacyjne ćwiczenie: robimy asystenta do wyboru prezentów. W poprzednich modułach mieliśmy już:
- widżet w ChatGPT (Next.js 16 + Apps SDK), który pokazuje UI, stan i potrafi wywoływać callTool;
- prosty backend (przez Apps SDK / trasy Next.js), który zwracał mocki prezentów.
Teraz chcemy „wynieść mózg” naszego asystenta do osobnego MCP‑serwera. Ostatecznie obraz będzie wyglądał tak:
flowchart TD
subgraph ChatGPT
U[Użytkownik
na czacie]
W["Widżet App
(Apps SDK)"]
end
subgraph MCP‑klient
C[ChatGPT MCP client]
end
subgraph OurServer[Nasz MCP‑serwer]
T1[Tool: suggest_gifts]
R1[Resource: gift_catalog]
P1[Prompt: birthday_template]
end
U --> W
W -- callTool --> C
C <-- JSON-RPC / HTTP --> OurServer
OurServer --> C
C --> W
To znaczy teraz:
- model wewnątrz ChatGPT widzi nasz MCP‑serwer jako standardowy zestaw tools/resources/prompts;
- callTool z widżetu logicznie staje się wewnętrznym wywołaniem MCP;
- nasz serwer opisuje kontrakty (schemy, opisy) i realizuje logikę biznesową.
Do końca tej lekcji powinniście mieć osobny projekt Node/TypeScript z MCP‑serwerem, który:
- uruchamia się lokalnie jedną komendą;
- rejestruje co najmniej jedno narzędzie i jeden zasób;
- zwraca sensowne dane (nawet jeśli to proste mocki);
- jest ustrukturyzowany tak, by można go było dalej rozwijać.
Przy tym istniejącego backendu przez Apps SDK/Next.js teraz nie przepisujemy: zostaje jak jest, a MCP‑serwer uruchamiamy jako osobną usługę obok. Później będziecie mogli „podpiąć” go do ChatGPT App i stopniowo przenieść tam logikę prezentową zamiast starych atrap.
2. Stos: TypeScript + MCP SDK + transport HTTP
Będziemy pisać MCP‑serwer w TypeScript pod Node.js. Oficjalny JS/TS SDK dla MCP znajduje się w pakiecie @modelcontextprotocol/sdk. Przejmuje on rutynę związaną z JSON‑RPC, walidacją i konwersją schem: opisujecie argumenty przez schemy Zod, a SDK sam tłumaczy je na JSON Schema zrozumiałe dla modelu.
Do transportu potrzebujemy wariantu HTTP: ChatGPT komunikuje się ze zdalnymi MCP‑serwerami po sieci, a nie przez stdio/lokalnie. Specyfikacja MCP opisuje standardowy format „strumieniowego HTTP” — w praktyce ewolucję starego schematu HTTP+SSE. W praktyce to jeden HTTP‑endpoint, który obsługuje żądanie (POST/GET) i w razie potrzeby streamuje odpowiedź. W TypeScript‑SDK dla MCP zwykle jest już gotowy transport pod taki format, który można podłączyć do Expressa lub Hono.
Aby się nie rozpraszać, załóżmy, że mamy:
- obiekt serwera McpServer z @modelcontextprotocol/sdk;
- transport HTTP (np. StreamableHttpServerTransport lub podobny), który można zaprzyjaźnić z Expressem.
Dokładne nazwy klas mogą się nieco różnić między wersjami SDK, ale architektonicznie to zawsze:
- tworzycie obiekt serwera MCP;
- rejestrujecie na nim tools/resources/prompts;
- podłączacie transport do aplikacji HTTP.
3. Struktura projektu i przygotowanie
Zróbmy osobny folder na MCP‑serwer. Wygodnie trzymać go obok aplikacji frontendowej, ale jako osobny projekt Node:
chatgpt-gift-app/
app/ ← Next.js + Apps SDK (widżet)
mcp-server/ ← nasz MCP-serwer
Wewnątrz mcp-server:
mcp-server/
src/
server.ts ← punkt wejścia serwera MCP
gifts.ts ← logika biznesowa doboru prezentów
package.json
tsconfig.json
Prosty przykład gifts.ts zrobimy za chwilę, teraz skupmy się na server.ts.
Załóżmy, że projekt został już zainicjalizowany:
mkdir mcp-server
cd mcp-server
npm init -y
npm install typescript ts-node-dev zod express @modelcontextprotocol/sdk
tsconfig.json — zupełnie zwyczajny (esnext modules, target node, strict). Można wziąć z dowolnego waszego projektu TS.
4. Wynosimy logikę biznesową do osobnego modułu
Kusi, by od razu napisać server.registerTool(..., async () => {...}) i tam też sklecić całą logikę. Ale lepiej od początku rozdzielić:
- moduł, który nic nie wie o MCP, JSON‑RPC i innych strasznościach;
- moduł, który zna tylko MCP, ale niewiele wie o logice biznesowej.
W src/gifts.ts opiszemy prostą funkcję doboru prezentów:
// src/gifts.ts
export type GiftIdea = {
id: string;
title: string;
price: number;
occasion: string;
};
export type SuggestGiftsInput = {
age: number;
relationship: "friend" | "partner" | "child" | "coworker";
budget: number;
};
export function suggestGifts(input: SuggestGiftsInput): GiftIdea[] {
// na razie tylko mocki
return [
{
id: "book-1",
title: "Książka o ulubionym hobby",
price: Math.min(input.budget, 30),
occasion: "generic",
},
{
id: "game-1",
title: "Gra planszowa dla grupy",
price: Math.min(input.budget, 50),
occasion: "party",
},
];
}
Ta funkcja jest czysta: na wejściu parametry, na wyjściu tablica pomysłów. Można ją testować testami jednostkowymi, ponownie wykorzystać w innym miejscu i nie zależy w żaden sposób od MCP. Tak właśnie warto robić: serwerowa „obwódka” osobno, funkcje biznesowe osobno.
5. Tworzymy MCP‑serwer i podłączamy transport HTTP
Teraz punkt wejścia src/server.ts. Schematycznie potrzebujemy:
- utworzyć instancję serwera MCP;
- zarejestrować na nim narzędzia, zasoby i prompty;
- postawić serwer HTTP (np. Express) i podpiąć do niego transport MCP.
Zacznijmy od szkieletu:
// src/server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server";
import { StreamableHttpServerTransport } from "@modelcontextprotocol/sdk/transport/streamable-http";
const app = express();
// 1. Tworzymy serwer MCP
const mcpServer = new McpServer({
name: "gift-assistant-mcp",
version: "0.1.0",
});
// 2. Tutaj później zarejestrujemy tools/resources/prompts
// 3. Konfigurujemy transport nad HTTP
const transport = new StreamableHttpServerTransport({
path: "/mcp", // pojedynczy endpoint MCP
app, // wbudowujemy się w aplikację Express
});
transport.attach(mcpServer);
const PORT = process.env.PORT ?? 4000;
app.listen(PORT, () => {
console.log(`MCP server listening on http://localhost:${PORT}/mcp`);
});
Konkretne nazwy klas transportu mogą się różnić, ale wzorzec jest jeden: tworzycie HTTP‑endpoint i podłączacie do niego serwer MCP jako handler JSON‑RPC nad HTTP/strumieniem.
Na tym etapie serwer nie robi jeszcze nic użytecznego, ale potrafi już:
- przejść MCP‑handshake;
- odpowiadać na podstawowe żądania discovery (lista tools/resources/prompts — na razie pusta).
Kolejny krok — zarejestrować pierwsze narzędzie.
6. Rejestrujemy tool suggest_gifts przez MCP SDK
Oficjalny Apps SDK i dokumentacja MCP pokazują ten sam wzorzec rejestracji narzędzia: metoda registerTool, do której przekazujecie nazwę, deskryptor (tytuł, opis, schemat argumentów) i handler.
Typ SuggestGiftsInput opisaliśmy już w gifts.ts. Teraz dodamy schemat Zod, aby serwer mógł walidować wejściowe argumenty i automatycznie przekazać LLM poprawny JSON Schema.
// src/server.ts (fragment)
import { z } from "zod";
import { suggestGifts } from "./gifts";
const suggestGiftsInputSchema = z.object({
age: z.number().int().min(0).max(120),
relationship: z.enum(["friend", "partner", "child", "coworker"]),
budget: z.number().min(0),
});
Teraz rejestrujemy narzędzie:
// wciąż w server.ts
mcpServer.registerTool(
"suggest_gifts",
{
title: "Suggest gift ideas",
description:
"Dobiera pomysły na prezenty w oparciu o wiek, relację i budżet.",
// SDK przekonwertuje schemat Zod na JSON Schema dla modelu
inputSchema: suggestGiftsInputSchema,
},
async ({ input }) => {
const ideas = suggestGifts(input);
const text = ideas
.map(
(g) =>
`• ${g.title} — ~${g.price} USD (occasion: ${g.occasion}, id: ${g.id})`
)
.join("\n");
return {
content: [
{
type: "text",
text,
},
],
// structuredContent można wykorzystać w widżecie
structuredContent: {
ideas,
},
};
}
);
Kluczowe punkty:
- inputSchema — schema Zod. SDK dla TS potrafi zamienić ją na JSON Schema i tym samym automatycznie opisuje narzędzie dla modelu.
- Handler przyjmuje obiekt z input (którego typ wynika ze schemy). Wewnątrz możecie wywołać swoją funkcję biznesową.
- W result zwracacie content — to tekst, który model zobaczy jako wynik, oraz opcjonalnie structuredContent z JSON‑strukturą, którą może potem zużyć wasz widżet.
Jeśli w poprzednich modułach robiliście narzędzie przez Apps SDK, ten kod powinien wyglądać bardzo znajomo: wzorzec jest identyczny, tylko teraz żyje w osobnym MCP‑serwerze.
7. Dodajemy zasób gift_catalog dla danych
Narzędzia to działania. Czasem chcemy też udostępniać dane jako zasób, by model mógł je czytać, wyszukiwać po nich albo by wasz widżet mógł dociągać szablony, komponenty itd. MCP osobno opisuje koncepcję zasobów z URI, typami MIME i zawartością.
Zróbmy prosty zasób gift_catalog, który zwraca listę dostępnych prezentów. Na razie to te same mocki, ale w realu mogłaby to być zrzutka z bazy lub product feed.
Najpierw sam katalog:
// src/gifts.ts (uzupełnienie)
export const giftCatalog: GiftIdea[] = [
{
id: "book-1",
title: "Książka o programowaniu",
price: 25,
occasion: "learning",
},
{
id: "lego-1",
title: "Zestaw LEGO",
price: 60,
occasion: "fun",
},
];
Teraz rejestrujemy zasób na serwerze:
// src/server.ts (fragment)
import { giftCatalog } from "./gifts";
mcpServer.registerResource(
"gift_catalog",
{
title: "Gift catalog",
description: "Prosty katalog prezentów do demo i debugowania.",
mimeType: "application/json",
},
async () => {
return {
contents: [
{
uri: "mcp://gift-catalog",
mimeType: "application/json",
text: JSON.stringify(giftCatalog, null, 2),
},
],
};
}
);
Co się tu dzieje logicznie:
- nazwa zasobu gift_catalog będzie widoczna klientowi przy discovery (w MCP‑inspektorze zobaczycie go potem na liście zasobów);
- deskryptor zawiera opis czytelny dla człowieka i typ MIME;
- handler zwraca tablicę contents z URI i tekstem — to standardowy format zasobu w MCP.
Później będziecie mogli:
- czytać ten zasób z klienta (np. agent lub inspektor);
- używać go jako szablonów/danych dla UI;
- eksperymentować: jak model wykorzystuje gotowy katalog, by wyjaśniać użytkownikowi dostępne opcje.
8. Rejestrujemy prosty prompt
Trzeci byt MCP — to prompty, wcześniej przygotowane podpowiedzi. Pozwalają nie powtarzać długich promptów systemowych czy użytkownika, tylko przechowywać je na serwerze pod nazwami.
Zróbmy mini‑przykład: prompt birthday_gift, który można wywołać jako „wstępnie wypełniony szablon rozmowy o prezencie na urodziny”.
// src/server.ts (fragment)
mcpServer.registerPrompt("birthday_gift", {
title: "Birthday gift helper",
description: "Szablon zapytania do doboru prezentu na urodziny.",
messages: [
{
role: "system",
content:
"Jesteś asystentem do wyszukiwania prezentów. Zadawaj pytania doprecyzowujące i proponuj kilka opcji.",
},
{
role: "user",
content:
"Potrzebuję prezentu na urodziny. Zadaj niezbędne pytania i pomóż wybrać.",
},
],
});
Pod spodem MCP pozwoli klientom:
- pobrać listę promptów (w inspektorze zobaczycie birthday_gift);
- pobrać jego zawartość i użyć jako podstawowej podpowiedzi dla modelu.
Osobno, w module o system‑prompcie i instrukcjach, szczegółowo omawiamy, jak takie prompty łączą się z globalnymi instrukcjami aplikacji. Tutaj ważne jest po prostu „zobaczyć” je jako część MCP‑serwera.
9. Jak to wszystko działa w runtime
Złóżmy obraz w całość.
Gdy klient (np. MCP Inspector lub ChatGPT) łączy się z naszym HTTP‑endpointem /mcp:
- następuje handshake: klient i serwer wymieniają informacje o obsługiwanych możliwościach (tools/resources/prompts itd.);
- klient wywołuje metody discovery: pobiera listę narzędzi, zasobów i promptów wraz z ich opisami i schemami;
- gdy model decyduje się wywołać narzędzie, formuje żądanie JSON‑RPC z metodą w rodzaju tools/call lub podobną — SDK po stronie serwera zamienia to na wewnętrzne wywołanie handlera z registerTool;
- handler wykonuje logikę biznesową (u nas to suggestGifts lub zwrócenie giftCatalog) i zwraca wynik w ustandaryzowanym formacie;
- SDK serializuje odpowiedź z powrotem do JSON‑RPC i odsyła klientowi przez ten sam transport HTTP/strumień.
Wszystkie szczegóły JSON‑RPC, tworzenia id, routingu metod itd. pozostają wewnątrz @modelcontextprotocol/sdk. Dla was interfejs jest bardzo podobny do Apps SDK: pracujecie z registerTool/registerResource/registerPrompt i handlerami, nie martwiąc się o protokół.
10. Lokalny start i pierwszy prosty test
Załóżmy, że dodaliście wszystko, co powyżej. Zostało uruchomić.
W package.json można dodać skrypt:
{
"scripts": {
"dev": "ts-node-dev src/server.ts"
}
}
Uruchamiamy:
npm run dev
W konsoli powinno pojawić się coś w rodzaju:
MCP server listening on http://localhost:4000/mcp
Pełną inspekcję i ręczne wywołania narzędzi zrobimy w następnej lekcji przez MCP Inspector / MCP Jam. Ale już teraz można zrobić super prosty smoke‑test przez curl:
curl -X POST http://localhost:4000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
Ten curl — czysto fakultatywny smoke‑test dla tych, którzy lubią patrzeć na „surowe” odpowiedzi JSON. W realnym developmentcie prawie zawsze komunikujecie się z MCP‑serwerem przez SDK, a nie ręcznie sklejacie żądania JSON‑RPC.
Dokładna nazwa metody zależy od wersji protokołu i SDK, ale chodzi o to, że dostaniecie JSON‑listę, gdzie wśród tools będzie widoczne suggest_gifts. Jeśli metoda się nie zgadza — nic strasznego: celem lekcji nie jest pamiętanie wszystkich nazw, tylko to, byście nie bali się patrzeć na odpowiedzi JSON i rozumieli ich strukturę, dzięki poprzednim lekcjom.
11. Połączenie z naszym ChatGPT App i dalszy rozwój
Na razie MCP‑serwer żyje samodzielnie. W kolejnych modułach:
- podłączycie go do MCP Inspector i nauczycie się debugować tools/resources/prompts osobno, bez dotykania ChatGPT;
- skonfigurujecie ChatGPT App tak, aby widział ten MCP‑serwer jako źródło narzędzi;
- przeniesiecie część logiki, która wcześniej była zaimplementowana w Apps SDK (np. przez wbudowane tools), do warstwy MCP;
- dodacie autoryzację, logowanie, scenariusze strumieniowe — już nad gotowym szkieletem.
Teraz ważne jest, że:
- macie osobny serwis odpowiedzialny za „umiejętności” i „dane” aplikacji;
- ten serwis rozmawia z klientami przez standard MCP, a nie przez niestandardowy REST;
- potraficie już ręcznie rejestrować narzędzia, zasoby i prompty, nie bojąc się protokołu.
12. Kilka słów o strukturze kodu i best practices
Nawet na tak małym przykładzie można zaszczepić dobre nawyki.
Po pierwsze, trzymajcie konfigurację serwera osobno. Wszystko, co dotyczy nazwy, wersji, logowania, ustawień transportu (port, ścieżka /mcp), łatwo wynieść do małego modułu config.ts. Potem, gdy będziecie wdrażać na Vercel lub za MCP‑gateway, dojdą zmienne środowiskowe i sami sobie podziękujecie.
Po drugie, starajcie się, aby metody registerTool/registerResource/registerPrompt pozostawały maksymalnie „cienkie”. Opisy schem, tekstów i logika biznesowa — to rzeczy, które dobrze wyglądają w osobnych plikach:
- gifts.ts — funkcje wyboru prezentów;
- catalog.ts — praca z katalogiem produktów;
- prompts.ts — zestaw promptów.
Sam server.ts staje się wtedy czymś w rodzaju „providera MCP”, który tylko scala wszystko w całość.
Po trzecie, pamiętajcie, że MCP‑serwer z natury jest reaktywny: oczekuje podłączeń klientów i ich żądań. To znaczy, że wszelkie blokujące lub nadmiernie długie operacje wewnątrz narzędzi będą bezpośrednio wpływać na UX w ChatGPT. W kolejnych modułach porozmawiamy o timeoutach, operacjach asynchronicznych i odpowiedziach strumieniowych, ale już teraz warto myśleć, które operacje można wynieść w tło, a które muszą odpowiadać szybko.
Insight: ChatGPT wspiera tylko część MCP
Ważne zrozumieć: ChatGPT Apps używają MCP jako transportu i formatu, ale nie są pełnoprawnym MCP‑klientem. Jeśli czytać tylko protokół, łatwo zbudować błędne oczekiwania co do działania w runtime.
Co obiecuje „czysty” MCP:
- zasoby (resources) mogą być czytane dynamicznie, na żądanie klienta, a nie raz na zawsze;
- serwer może wysyłać notyfikacje resourceChanged/toolChanged i w ten sposób „wypychać” aktualizacje bez restartu klienta;
- można zbudować dość elastyczny system, w którym zestaw tools/resources/prompts jest sterowany konfiguracją lub stanem zewnętrznym.
W kontekście ChatGPT Apps tak nie jest. Dla aplikacji obraz jest dużo bardziej statyczny:
- przy rejestracji App ChatGPT jednorazowo odczytuje opis wszystkich tools i resources;
- następnie ta konfiguracja w praktyce jest cache’owana jako część wersji aplikacji;
- dynamiczne aktualizacje przez notyfikacje MCP nie są wspierane — platforma je po prostu ignoruje.
13. Typowe błędy przy pisaniu pierwszego MCP‑serwera
Błąd nr 1: Wrzucenie całej logiki biznesowej prosto do registerTool.
Pokusa „szybko napisać wszystko w handlerze narzędzia” jest ogromna, zwłaszcza w przykładzie edukacyjnym. Ale potem robi się z tego nieczytelny kombajn, gdzie wymieszane są walidacja, praca z DB i formatowanie odpowiedzi. Lepiej od razu wynieść funkcje biznesowe (suggestGifts, pracę z katalogiem) do osobnych modułów, a w handlerze robić tylko „sklejkę”.
Błąd nr 2: Sztywne przywiązanie do konkretnych nazw metod JSON MCP.
Czasem studenci zaczynają pisać if (method === "tools/list") i ręcznie parsować JSON. Tego robić nie trzeba: to zadanie SDK. Specyfikacja MCP i nazwy metod mogą ewoluować, a SDK bierze to na siebie. Używajcie registerTool, registerResource, registerPrompt i pozwólcie bibliotece zdecydować, jak ma to wyglądać w JSON‑RPC.
Błąd nr 3: Nierozważanie transportu i próba karmienia ChatGPT serwerem stdio.
Transport stdio jest idealny dla lokalnych klientów, jak środowiska desktopowe, gdzie klient może uruchamiać serwer jako podproces. Ale ChatGPT rozmawia po HTTPS i potrzebuje endpointu HTTP/strumień. Próba „przepchnięcia stdio” przez tunel kończy się bólem. Dla ChatGPT App od razu róbcie transport HTTP (Streamable HTTP).
Błąd nr 4: Ignorowanie typów MIME i struktury zasobów.
W zasobach ważna jest nie tylko zawartość, ale też typ (mimeType) i URI. Jeśli wszędzie pisać text/plain i bezrefleksyjnie wrzucać ciągi JSON, klientom (i inspektorom) trudniej będzie zrozumieć, jakie to dane. Starajcie się podawać poprawne typy MIME (application/json, text/html dla szablonów UI itp.) i stabilne URI.
Błąd nr 5: Używanie MCP‑serwera jako „losowego HTTP‑API”.
Czasem kusi: „Skoro mam już Express, powieszę jeszcze /api/whatever i będę walić tam bezpośrednio”. Mieszanie endpointu MCP z dowolnym RESTem nie jest dobrym pomysłem: komplikuje konfigurację, routing i bezpieczeństwo. Prościej mieć jasny kontrakt: /mcp dla MCP, osobne ścieżki dla innych potrzeb albo wręcz inny serwis. W produkcji to szczególnie ważne dla konfiguracji bramek (gateways) i autoryzacji. Innymi słowy, nie zamieniajcie MCP‑serwera w „losowe HTTP‑API” — zbiór przypadkowych endpointów HTTP niezwiązanych z kontraktem MCP.
Błąd nr 6: Brak logowania przychodzących i wychodzących komunikatów MCP.
Bez logów MCP‑serwer staje się czarną skrzynką: „coś nie działa, ale nie wiem co”. Już w pierwszym serwerze warto przynajmniej do stderr pisać zwięzłe logi ustrukturyzowane: metoda narzędzia, status, czas wykonania. Najważniejsze — nie logować danych wrażliwych i tokenów, do tego wrócimy osobno przy bezpieczeństwie.
Błąd nr 7: Próba debugowania wszystkiego naraz przez ChatGPT, bez inspektora.
Częsty obrazek: student pisze MCP‑serwer, od razu podłącza go do ChatGPT App i wszystko „niezrozumiale się psuje”. Tymczasem inspektor ani razu nie został uruchomiony. W efekcie trudno ustalić, czy problem jest w protokole, w serwerze, w Apps SDK czy w zachowaniu modelu. Właściwa ścieżka — najpierw upewnić się, że MCP‑serwer działa poprawnie w izolacji (przez MCP Jam / Inspector), a dopiero potem podłączać go do aplikacji.
GO TO FULL VERSION