Jak stworzyć serwer MCP? STDIO + Streamable HTTP z Hono

Możliwości serwera MCP są ogromne. MCP umożliwia modelom AI uzyskać dostęp do zasobów na serwerze, zarządzać danymi i integrować się z zewnętrznymi danymi. Deweloper, który potrafi coś takiego stworzyć, może znacząco rozszerzyć możliwości klientów MCP (jak Claude Desktop) dla siebie i swojej firmy.
Ten post powstał dla wersji 2025-03-26 standardu MCP. W najnowszych wersji wprowadzono parę zmian ale poniższe przykłady będą ciągle działać. Pełna aktualizacja wkrótce.
Czym jest protokół MCP (Model Context Protocol)?
Model Context Protocol to otwarty standard stworzony przez firmę Anthropic, która stoi za rodziną modeli Claude. Jest to protokół, który definiuje wspólny interfejs standaryzujący sposób dostarczania kontekstu dla dużych modeli językowych. Dzięki temu możemy podłączać różne narzędzia do modeli LLM. To trochę jak wspólny standard dla wtyczek do Chrome, które działają na każdej przeglądarce Chromium, albo port USB-C.
Klienci MCP
By korzystać z MCP, potrzebny jest klient, który implementuje ten protokół. Najpopularniejszym jest aktualnie Claude Desktop. Oprócz tego mamy klientów w AI IDE, np.: Cursor, Windsurf, GitHub Copilot i inne. Warto również zwrócić uwagę na Alice stworzoną przez Adama Gospodarczyka, znanego jako overment.
A listę wszystkich ciekawych klientów MCP możesz znaleźć na Awesome List na GitHub.
Dlaczego warto stworzyć serwer MCP?
Jeśli korzystasz z Claude Desktop (albo innego klienta), to stworzenie własnego serwera może znacząco podnieść twoją produktywność i wykorzystanie narzędzi. Jest to ustandaryzowany sposób, by dać dostęp klientowi do konkretnych danych. To w połączeniu z innymi ogólnodostępnymi serwerami może przyspieszyć twoją pracę. Jest też kwestia bezpieczeństwa. Własny serwer MCP może być proxy między klientem a poufnymi danymi. Zamiast dawać dostęp do wszystkiego, możesz selekcjonować konkretne dane.
Przykładem zastosowania będzie połączenie danych z Google Drive, Confluence, Slack i własnej hurtowni danych. Do pierwszych trzech są dostępne gotowe serwery, a do danych z hurtowni możesz postawić własny serwer. Szczególnie, że nie jest to trudne. Dzięki temu zyskujesz możliwość analizowania danych ze wszystkich źródeł w jednym miejscu.
Dla deweloperów jest oficjalny zestaw SDK w kilku różnych językach (na dzień 8.06 są dostępne repozytoria dla TypeScript, Python, Java, Kotlin, C#).
Anatomia serwera MCP
Budując serwer MCP, trzeba wiedzieć, z czego można skorzystać do budowania swojego rozwiązania. Aktualnie są wspierane następujące możliwości:
- Resources - pobieranie danych
- Tools - dynamiczne operacje
- Prompts - pobieranie promptów
- Sampling - do odpytywania LLM przez klienta, może być użyte do stworzenia architektury human-in-the-loop
Trzy pierwsze są aktualnie najczęściej wspierane i poświęcę im więcej uwagi. Do tematu Sampling wrócę w innym wpisie.
Resources
Zachowanie resources w pełni zależy od wykorzystywanego klienta. Claude Desktop wymaga dodawania tych danych bezpośrednio przy zapytaniu. Czyni to Resources mniej użytecznymi w Claude Desktop.
Najbardziej podstawową możliwością dla dowolnego serwera MCP jest dostęp do zasobów. Resources jest podobne do zapytania GET w REST - zwraca dane bez ich modyfikacji z różnych źródeł danych. Mogą to być dane statyczne, rezultat zapytania API, dane z bazy danych, zawartość plików, obrazki i wiele więcej.
By zaimplementować najprostsze pobieranie danych, potrzebujemy podać nazwę oraz URI, pod którym zasób będzie dostępny. Najprościej pokazać to na przykładzie.
1server.resource(
2 'currentDate',
3 "date://currentDate",
4 async (uri) => ({
5 contents: [{
6 uri: uri.href,
7 text: new Date().toDateString()
8 }]
9 })
10)
11Najważniejsza jest funkcja, która będzie zwracać dane. Możemy zwrócić dwa rodzaje danych - tekst i dane binarne. Tekst nadaje się dla standardowych danych (np. z bazy danych) i JSON-ów, tak jak w przykładzie. Jeśli zwracamy obrazki, pliki PDF, audio lub inne, to musimy wykorzystać dane binarne zakodowane w base64. Bardzo istotne jest, by podać odpowiedni mimeType, który pozwoli klientowi odpowiednio obsłużyć plik.
1server.resource(
2 'catImage',
3 "image://cat",
4 async (uri) => ({
5 contents: [{
6 uri: uri.href,
7 blob: fs.readFileSync(path.resolve(__dirname, "./cat.jpg")).toString('base64'),
8 mimeType: 'image/jpeg'
9 }]
10 })
11)Przykłady powyżej są statycznymi danymi. Oprócz tego mamy możliwość stworzenia bardziej dynamicznych zasobów, np.: pobieranie zasobu na bazie konkretnej danej.
1server.resource(
2 'users',
3 new ResourceTemplate("users://{userId}", {
4 list: () => {
5 return {
6 resources: users.map(user => ({
7 name: `User #${user.id}`,
8 uri: `users://${user.id}`,
9 description: `The user with id#${user.id}`,
10 })),
11 }
12 },
13 complete: {
14 userId: () => {
15 return users.map(user => user.id);
16 }
17 }
18 }),
19 async (uri, { userId }) => ({
20 contents: [{
21 uri: uri.href,
22 text: JSON.stringify(users.find(user => user.id === userId))
23 }]
24 })
25)
26Powyżej jest przykład dynamicznego zasobu, który na bazie id użytkownika zwraca jego dane. Wykorzystujemy tutaj ResourceTemplate, by odpowiednio skonfigurować zasób, ponieważ mamy tutaj kilka rzeczy:
- "users://{userId}" - ten string będzie się wydawał znajomy wszystkim, co piszą backend. {{userId}} jest unikalnym identyfikatorem, który zostanie wykorzystany do wyboru konkretnego zasobu.
- list: () => {} - zwraca listę zasobów, która może być wybrana przez użytkownika.
- complete: {} - obiekt, w którym definiujemy, jakie wartości może przyjąć unikalny identyfikator.
W przypadku funkcji, która obsługuje ten zasób, jako jeden z parametrów dostajemy wartość unikalnego identyfikatora, który zdefiniowaliśmy.
Tools
Narzędzia są najbardziej podstawowym zastosowaniem dla MCP. Z założenia są to akcje, które będą modyfikować stan po stronie serwera. Definiujemy je bardzo podobnie do zasobów. Mamy tutaj 3 elementy:
- nazwa
- wymagane dane do walidacji
- funkcja do obsługi
1server.tool(
2 'addUser',
3 {
4 name: z.string(),
5 email: z.string().email()
6 },
7 async ({ name, email }) => {
8 const newUser = {
9 id: crypto.randomUUID(),
10 name,
11 email
12 }
13 users.push(newUser)
14 return {
15 content: [{
16 type: "resource", resource: {
17 text: JSON.stringify(newUser),
18 uri: `users://${newUser.id}`
19 }
20 }]
21 }
22 }
23)
24W funkcji obsługującej akcje musimy zwrócić dane albo w postaci tekstu, albo informacji o zasobie, gdzie można te informacje znaleźć.
Prompts
Oprócz zasobów i akcji możemy jeszcze zdefiniować prompty, które serwer zwróci użytkownikowi. Możemy definiować od prostych promptów aż do bardziej zaawansowanych przypadków z przekazywaniem danych (podobnie jak w akcjach).
1server.prompt(
2 'tell-joke',
3 "use to generate joke",
4 () => {
5 return {
6 description: "use to generate jokes",
7 messages: [
8 {
9 role: "user",
10 content: {
11 type: "text",
12 text: "Generate funny joke"
13 }
14 }
15 ]
16 }
17 }
18)
19Jak stworzyć własny serwer? Przykłady w kodzie
Aktualnie mamy dwa główne sposoby, by uruchomić serwer: jako STDIO oraz Streamable HTTP. Do niedawna było jeszcze SSE, ale aktualnie jest już niepolecane, więc nie będę omawiać.
Kod serwera
Tutaj nie ma wiele do opisywania, bo tworząc instancję serwera, podajemy tylko nazwę oraz wersję.
1import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { z } from "zod";
3
4const server = new McpServer({
5 name: 'Utils',
6 version: '0.0.1'
7})
8
9// przykłady powyżej
10
11export const getServer = () => server;
12Taki serwer można wykorzystać zarówno do uruchomienia jako STDIO, jak i jako Streamable HTTP.
Konfiguracja STDIO
Dla STDIO nie musimy za wiele robić poza stworzeniem instancji StdioServerTransport i podpięcia pod serwer. Taki serwer można podpiąć do dowolnego klienta, np.: Claude Desktop.
1const server = getServer();
2const transport = new StdioServerTransport();
3await server.connect(transport);
4Konfiguracja Streamable HTTP z Hono
Tu jest trochę więcej zabawy, ponieważ Streamable HTTP zostało zaprojektowane pod Express i wszystkie przykłady pokazują integrację z tą biblioteką. Ja jestem fanem Hono, które daje większe możliwości w kontekście wdrożenia na różnych platformach. By zintegrować z MCP, musimy skorzystać z biblioteki fetch-to-node, która przekształca request z Hono na taki, który rozumie StreamableHTTPServerTransport. Poniższy kod można traktować jako szablon i wykorzystać do dowolnego serwera.
1import { Hono } from 'hono'
2import { getServer } from './server';
3import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4import { toFetchResponse, toReqRes } from "fetch-to-node";
5
6const app = new Hono()
7
8app.post('/mcp', async (c) => {
9 const { req, res } = toReqRes(c.req.raw);
10 const body = await c.req.json();
11 const server = getServer();
12 try {
13 const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
14 sessionIdGenerator: undefined,
15 });
16
17 await server.connect(transport);
18 await transport.handleRequest(req, res, body);
19
20 res.on('close', () => {
21 console.log('Request closed');
22 transport.close();
23 server.close();
24 });
25
26 return toFetchResponse(res);
27 } catch (error) {
28 console.error('Error handling MCP request:', error);
29 if (!res.headersSent) {
30 return c.json({
31 jsonrpc: '2.0',
32 error: {
33 code: -32603,
34 message: 'Internal server error',
35 },
36 id: null,
37 });
38 }
39 }
40})
41
42export default app
43Po uruchomieniu można testować serwer MCP.
Testowanie serwera MCP
Gotowy serwer warto przetestować. Twórcy protokołu stworzyli narzędzie do testów serwerów MCP (modelcontextprotocol/inspector). Jest to najprostszy sposób na przetestowanie tego protokołu.
Narzędzie można uruchomić z poziomu narzędzia CLI. STDIO:
1bunx @modelcontextprotocol/inspector bun run 12_mcp_server/index.ts
2
Wystarczy kliknąć Connect i możemy testować serwer.
Streamable HTTP:
1bunx @modelcontextprotocol/inspector
Musimy wybrać jako typ Streamable HTTP i wpisać adres serwera.
Resources

W przypadku Resources mamy możliwość albo wybrania konkretnego zasobu z listy, albo mamy sekcję Resource Templates, która daje wygodniejszy sposób pobrania danych (tutaj możemy wykorzystać sekcję list i complete z konfiguracji).
Tools

Dla narzędzi mamy osobną zakładkę i warto zwrócić uwagę, że automatycznie jest tworzony formularz na bazie wymaganych danych.
Prompts

Sekcja Prompts jest bardzo podobna do sekcji Resources i mamy dostęp do skonfigurowanych promptów.
Konfiguracja Claude Desktop pod MCP
Warto również przetestować serwer STDIO w Claude Desktop, by mieć pewność, że wszystko działa. Możesz do tego wykorzystać darmowe konto Claude. Aby dodać nowy serwer MCP, musisz wejść w Settings > Developer > Edit Config. Otworzy się wtedy lokalizacja pliku konfiguracyjnego claude_desktop_config.json, do którego musisz dodać poniższy wpis:
1"mcpServers": {
2 "local": {
3 "command": "/Users/olek/.bun/bin/bun",
4 "args": ["run", "/Users/olek/Documents/prywatne/ai-engineer-101/12_mcp_server/index.ts"]
5 }
6 }
7Teraz musisz zrestartować aplikację i możesz testować swój serwer.

Resources są dostępne, jeśli bezpośrednio dodasz do zapytania (osobiście uważam to za wadę)

Narzędzia są dostępne w tle i jeśli chat zauważy, że może je wykorzystać, to zrobi to, o ile nie wyłączymy danego narzędzia.
Co dalej?
MCP daje jeszcze większe możliwości dla konfiguracji serwera. Istotnym tematem jest autoryzacja, którą omówię w osobnym wpisie, oraz sampling, który umożliwia implementację human-in-the-loop. Ten standard też się cały czas rozwija, więc pewnie w przyszłości dostaniemy nowe możliwości. A na razie warto eksperymentować z aktualnymi.


