Przejdź do głównej zawartości

Budowanie niestandardowych serwerów MCP

Gotowy katalog MCP jest ogromny, ale kończy się na Twoim firewallu. Twoje AI potrafi przeszukać GitHub i odczytać Postgres, lecz nie tknie wewnętrznej wiki, w której zapisana jest każda decyzja architektoniczna, ani autorskiego API do wdrożeń, które rozumie tylko Twój zespół platformowy. W chwili, gdy ktoś z zespołu pyta „dlaczego AI ciągle ignoruje nasz runbook?”, odpowiedź jest zawsze ta sama: nic go z tym runbookiem nie łączy.

Niestandardowy serwer MCP to naprawia. To mały program, który udostępnia Twoje wewnętrzne systemy jako narzędzia, które AI może wywołać — skrobak Confluence, wrapper wokół wewnętrznego API REST, helper do zapytań do zastrzeżonego magazynu danych. Ten przewodnik przeprowadza przez budowę takiego serwera od początku do końca w TypeScript z oficjalnym SDK, a następnie zarejestrowanie go w Cursorze, Claude Code i Codeksie.

  • Działający szkielet serwera MCP zbudowany na @modelcontextprotocol/sdk i Zod, rejestrujący prawdziwe narzędzie z walidacją danych wejściowych
  • Aktualne API server.registerTool() (stary kształt server.tool() jest przestarzały od SDK v1.29+)
  • Dokładną konfigurację łączącą Twój serwer z Cursorem, Claude Code i Codeksem
  • Prompty do skopiowania, dzięki którym AI samo zbuduje szkielet serwera i doda dla Ciebie nowe narzędzia
  • Listę trybów awarii dla błędów, na które trafia każdy pierwszy serwer MCP (zaśmiecanie stdout, ramkowanie JSON-RPC, walidacja schematu)

Nie musisz pisać szablonowego kodu ręcznie. Najszybsza droga to zlecić agentowi AI zbudowanie szkieletu serwera, a potem go przejrzeć i wzmocnić. Zacznij od tego promptu.

Zainicjuj projekt Node i zainstaluj SDK, Zod oraz konwerter HTML na Markdown.

Okno terminala
npm init -y
npm install @modelcontextprotocol/sdk zod turndown
npm install -D typescript @types/node @types/turndown
npx tsc --init

Rejestracja narzędzia to fragment, który większość poradników robi źle. Niskopoziomowe wywołanie server.tool(name, shape, handler) jest przestarzałe w aktualnym SDK — użyj server.registerTool(name, { description, inputSchema }, handler) ze schematem obiektu Zod. Opis to to, co czyta model, decydując, kiedy wywołać narzędzie, więc niech będzie konkretny.

src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import TurndownService from "turndown";
const server = new McpServer({ name: "internal-docs", version: "1.0.0" });
const turndown = new TurndownService();
server.registerTool(
"get_doc",
{
description: "Fetch an internal Confluence page and return it as Markdown",
inputSchema: { url: z.string().url() },
},
async ({ url }) => {
try {
const response = await fetch(url);
if (!response.ok) {
return {
isError: true,
content: [{ type: "text", text: `Fetch failed: HTTP ${response.status}` }],
};
}
const html = await response.text();
return { content: [{ type: "text", text: turndown.turndown(html) }] };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [{ type: "text", text: `Error scraping ${url}: ${message}` }],
};
}
},
);
const transport = new StdioServerTransport();
await server.connect(transport);

Dwa szczegóły, które gryzą: inputSchema przyjmuje surowy obiekt kształtu Zod ({ url: z.string().url() }), a error jest typowany jako unknown w trybie strict TypeScript, więc musisz go zawęzić (error instanceof Error ? ...), zanim odczytasz .message. Zwrócenie isError: true pozwala modelowi zobaczyć błąd i zareagować, zamiast po cichu dostać pustą treść.

Okno terminala
npx tsc

To wygeneruje dist/server.js, na który wskazuje każdy z poniższych klientów. Trzymaj console.log z dala od serwera — na transporcie stdio stdout to kanał JSON-RPC, a zabłąkana linia logu uszkadza strumień protokołu. Loguj zamiast tego do stderr (console.error).

Cursor i Claude Code czytają ten sam kształt JSON mcpServers, więc te dwie zakładki są celowo identyczne — a jeśli wolisz, Claude Code może zarejestrować serwer jednym poleceniem CLI. Codex używa TOML pod [mcp_servers.<id>], gdzie transport stdio jest implikowany obecnością command (nie ma klucza transport).

Dodaj do .cursor/mcp.json (projekt) lub ~/.cursor/mcp.json (globalnie):

{
"mcpServers": {
"internal-docs": {
"command": "node",
"args": ["/abs/path/to/dist/server.js"]
}
}
}

Zrestartuj klienta, a następnie poproś go o wywołanie get_doc na wewnętrznym URL-u, by potwierdzić, że narzędzie jest podpięte.

Gdy skrobak tylko do odczytu działa, rozwijaj serwer, dodając narzędzia — każde to kolejne wywołanie registerTool. Pozwól AI rozszerzać własny zestaw narzędzi.

Klient pokazuje serwer jako „failed” bez żadnych narzędzi. Niemal zawsze to problem ze stdout. Każdy console.log, baner czy zależność, która drukuje na stdout, uszkadza strumień JSON-RPC na transporcie stdio. Przenieś całe logowanie do console.error (stderr) i przebuduj.

Błędy parsowania JSON-RPC / serwer się łączy, ale narzędzia nigdy się nie pojawiają. Prawdopodobnie masz przestarzałe SDK lub wciąż wołasz server.tool(). Potwierdź, że npm view @modelcontextprotocol/sdk version to 1.29+ i że przeszedłeś na registerTool. Mieszanie przestarzałego i nowego API w jednym serwerze to częsta przyczyna cichych awarii listy narzędzi.

„Transport mismatch” lub serwer HTTP nigdy nie zostaje osiągnięty. stdio i HTTP konfiguruje się inaczej. Dla stdio klient uruchamia Twój proces przez command/args. Dla zdalnego serwera udostępniasz zamiast tego Streamable HTTP i wskazujesz klientowi url. Nie wstawiaj klucza transport do TOML Codeksa — jest wnioskowany.

Walidacja Zod odrzuca każde wywołanie. Przekaż do inputSchema surowy kształt ({ url: z.string().url() }), a nie gotowy z.object(...), i upewnij się, że opis narzędzia mówi modelowi, czym jest każdy argument — niejasny opis prowadzi do tego, że model wysyła zniekształcone argumenty.

Ścieżki względne zawodzą przy uruchomieniu. Klienci MCP uruchamiają Twój serwer z własnego katalogu roboczego, a nie z korzenia Twojego projektu. Użyj ścieżki bezwzględnej do dist/server.js w każdej konfiguracji powyżej.