Tworzenie własnego serwera MCP
Własny serwer MCP to mały program na @modelcontextprotocol/sdk i Zod, który udostępnia wewnętrzne API, bazę danych lub CLI jako narzędzia wywoływane przez AI przez server.registerTool(). Może nieść trzy prymitywy — tools, resources i prompts — działa po stdio lub HTTP i trafia na produkcję jako pakiet npm, obraz Dockera albo zdalna usługa.
Gotowy katalog MCP jest ogromny, ale kończy się na Twoim firewallu. Twoja firma ma wewnętrzne API do feature flags, system incydentów, którego nikt inny nie używa, pipeline wdrożeń zbudowany na Kubernetes i wiki, w której zapisana jest każda decyzja architektoniczna. AI potrafi przeszukać GitHub i odczytać Postgres; żadnego z tych systemów nie tknie. Więc ręcznie wklejasz odpowiedzi API do chatu. Jesteś ludzkim serwerem MCP i jesteś wąskim gardłem.
Własny serwer tę lukę zamyka, a jest prostszy, niż się wydaje. SDK obsługuje protokół; Ty piszesz tę część, która faktycznie rozmawia z Twoim systemem. Ten przewodnik buduje taki serwer od początku do końca, rejestruje go w Cursorze, Claude Code i Codeksie, a potem go wzmacnia i wydaje.
Co daje własny serwer MCP
Dział zatytułowany „Co daje własny serwer MCP”- Działający szkielet serwera na
@modelcontextprotocol/sdki Zod, rejestrujący prawdziwe narzędzie z walidacją danych wejściowych - Aktualne API
server.registerTool()i powód, dla którego stary kształtserver.tool()po cichu psuje wykrywanie narzędzi - Dokładną konfigurację łączącą serwer z Cursorem, Claude Code i Codeksem
- Tools, resources i prompts — trzy prymitywy MCP i moment, w którym każdy z nich jest właściwym kształtem
- Wzorce opakowywania wewnętrznych API REST i narzędzi CLI bez otwierania dziury na shell injection
- Testowanie, debugowanie i trzy opcje wdrożenia: pakiet npm, Docker i zdalne HTTP
- Prompty do skopiowania, dzięki którym AI samo zbuduje szkielet serwera i doda narzędzia
- Listę trybów awarii dla błędów, na które trafia każdy pierwszy serwer MCP
Najpierw pozwól agentowi napisać szkielet
Dział zatytułowany „Najpierw pozwól agentowi napisać szkielet”Nie musisz pisać szablonowego kodu ręcznie. Najszybsza droga to zlecić agentowi zbudowanie szkieletu, a potem przejrzeć i wzmocnić to, co wróci.
Reszta tego przewodnika to wiedza potrzebna, żeby ten wynik przejrzeć — i żeby napisać go samodzielnie, kiedy szkielet okaże się błędny.
Twój pierwszy serwer MCP w 10 minut
Dział zatytułowany „Twój pierwszy serwer MCP w 10 minut”-
Zainicjuj projekt.
Okno terminala mkdir my-mcp-server && cd my-mcp-servernpm init -ynpm install @modelcontextprotocol/sdk zod -
Utwórz serwer. Dodaj to do
index.mjs:#!/usr/bin/env nodeimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';import { z } from 'zod';import { getFeatureFlags } from './tools/feature-flags.mjs';const server = new McpServer({name: 'my-first-mcp',version: '1.0.0',});server.registerTool('get_feature_flags',{description: 'Returns active feature flags for a given environment',inputSchema: {environment: z.enum(['dev', 'staging', 'production']).describe('Target environment'),},},async ({ environment }) => ({content: [{type: 'text',text: JSON.stringify(getFeatureFlags({ environment }), null, 2),}],}));const transport = new StdioServerTransport();await server.connect(transport);Trzymaj logikę flag w osobnym module, abyś mógł testować ją jednostkowo bez uruchamiania transportu. Dodaj
tools/feature-flags.mjs:tools/feature-flags.mjs const FLAGS = {dev: { darkMode: true, newCheckout: true, betaSearch: true },staging: { darkMode: true, newCheckout: true, betaSearch: false },production: { darkMode: true, newCheckout: false, betaSearch: false },};// Replace the lookup below with your actual feature-flag API call.export function getFeatureFlags({ environment }) {return FLAGS[environment];} -
Nadaj uprawnienia do wykonania.
Okno terminala chmod +x index.mjs -
Podłącz go do swojego edytora, korzystając z konfiguracji poniżej.
Rejestracja narzędzia zgodnie z aktualnym SDK
Dział zatytułowany „Rejestracja narzędzia zgodnie z aktualnym SDK”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) z kształtem Zod. Opis to to, co czyta model, decydując, kiedy wywołać narzędzie, więc niech będzie konkretny.
Ta sama idea w TypeScript, z obsługą błędów, której brakuje w wersji hello-world:
npm install @modelcontextprotocol/sdk zod turndownnpm install -D typescript @types/node @types/turndownnpx tsc --initimport { 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);Trzy szczegóły, które gryzą. inputSchema przyjmuje surowy obiekt kształtu Zod ({ url: z.string().url() }), a nie gotowy z.object(...). W trybie strict TypeScript error jest typowany jako unknown, więc musisz go zawęzić (error instanceof Error ? ...), zanim odczytasz .message. A zwrócenie isError: true pozwala modelowi zobaczyć błąd i zareagować, zamiast po cichu dostać pustą treść.
Zbuduj go:
npx tscTo wygeneruje dist/server.js, na który wskazuje każda konfiguracja klienta poniżej. Trzymaj console.log z dala od serwera — na transporcie stdio stdout jest kanałem JSON-RPC, a jedna zabłąkana linia logu uszkadza strumień protokołu. Loguj zamiast tego do stderr (console.error).
Podłączanie serwera do Cursora, Claude Code i Codeksa
Dział zatytułowany „Podłączanie serwera do Cursora, Claude Code i Codeksa”Rejestracja to jedno z tych miejsc, w których trzy narzędzia naprawdę się zbiegają. Cursor i Claude Code czytają ten sam kształt JSON mcpServers, więc te dwa bloki są celowo identyczne i możesz wkleić jeden w miejsce drugiego — Claude Code oferuje dodatkowo polecenie CLI, które zapisze ten plik za Ciebie. Różni się tylko Codex, i to wyłącznie formatem: 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"] } }}Albo dodaj ten sam JSON do .mcp.json w katalogu głównym projektu, albo zarejestruj go z terminala:
claude mcp add internal-docs -- node /abs/path/to/dist/server.jsSprawdź poleceniem claude mcp list, a serwer, który się nie ładuje, debuguj przez claude --debug "mcp".
Dodaj do ~/.codex/config.toml:
[mcp_servers.internal-docs]command = "node"args = ["/abs/path/to/dist/server.js"]Klucz transport nie jest potrzebny — stdio jest wnioskowany z command (serwer HTTP użyłby zamiast tego url).
Zrestartuj klienta, a potem poproś go o wywołanie jednego z Twoich narzędzi, żeby potwierdzić, że wszystko jest podpięte.
Tools, resources i prompts: trzy prymitywy MCP
Dział zatytułowany „Tools, resources i prompts: trzy prymitywy MCP”Serwery MCP udostępniają trzy typy możliwości, a wybór złego typu to powód, dla którego część serwerów jest nie do użycia.
Tools: funkcje, które wywołuje AI
Dział zatytułowany „Tools: funkcje, które wywołuje AI”Tools przyjmują ustrukturyzowane dane wejściowe, wykonują operację i zwracają wyniki. Tego będziesz używać najczęściej.
server.registerTool( 'search_incidents', // Tool name { description: 'Search the incident database', // Shown to the AI inputSchema: { // Zod raw shape query: z.string(), severity: z.enum(['P1', 'P2', 'P3', 'P4']).optional(), status: z.enum(['open', 'resolved', 'investigating']).optional(), }, }, async ({ query, severity, status }) => { const results = await searchIncidents({ query, severity, status }); return { content: [{ type: 'text', text: JSON.stringify(results, null, 2), }], }; });Resources: dane, które AI przegląda
Dział zatytułowany „Resources: dane, które AI przegląda”Resources to dane, które AI może przeglądać i odczytywać, jak pliki w systemie plików. Używaj ich, gdy chcesz, żeby AI odkrywało, co jest dostępne, zamiast pytać o konkretny element.
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
server.registerResource( 'runbooks', // A templated URI plus a `list` callback so the AI can browse available runbooks. new ResourceTemplate('runbook:///{id}', { list: async () => { const runbooks = await listRunbooks(); return { resources: runbooks.map(r => ({ uri: `runbook:///${r.id}`, name: r.title, description: r.summary, mimeType: 'text/markdown', })), }; }, }), { title: 'Runbooks', description: 'Operational runbooks for production services', mimeType: 'text/markdown', }, // Read handler. The matched `{id}` arrives in the second argument. async (uri, { id }) => { const content = await getRunbook(id); return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: content, }], }; });Prompts: szablony standaryzujące workflow
Dział zatytułowany „Prompts: szablony standaryzujące workflow”Prompts to gotowe szablony, które AI może wywołać. To one powstrzymują pięcioro inżynierów przed napisaniem pięciu różnych postmortemów.
server.registerPrompt( 'incident_postmortem', { description: 'Generate a postmortem document for an incident', argsSchema: { incidentId: z.string().describe('Incident ID to generate postmortem for'), }, }, async ({ incidentId }) => { const incident = await getIncident(incidentId); return { messages: [{ role: 'user', content: { type: 'text', text: `Write a postmortem for incident ${incident.id}: "${incident.title}". Timeline: ${JSON.stringify(incident.timeline)} Root cause: ${incident.rootCause || 'Unknown'} Impact: ${incident.impact}
Follow our postmortem template: title, summary, timeline, root cause analysis, action items with owners and due dates.`, }, }], }; });Rozbudowa serwera: jedno narzędzie naraz
Dział zatytułowany „Rozbudowa serwera: jedno narzędzie naraz”Gdy pierwsze narzędzie tylko do odczytu działa, każde kolejne to następne wywołanie registerTool. Pozwól AI rozszerzać własny zestaw narzędzi.
Opakowywanie wewnętrznego REST API
Dział zatytułowany „Opakowywanie wewnętrznego REST API”const API_BASE = process.env.INTERNAL_API_URL;const API_KEY = process.env.INTERNAL_API_KEY;
if (!API_KEY) { console.error('INTERNAL_API_KEY is required'); process.exit(1);}
async function apiCall(path, options = {}) { const response = await fetch(`${API_BASE}${path}`, { ...options, headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', ...options.headers, }, });
if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`); }
return response.json();}
server.registerTool( 'list_deployments', { description: 'List recent deployments for a service', inputSchema: { service: z.string(), limit: z.number().optional().default(10), }, }, async ({ service, limit }) => { const deployments = await apiCall(`/deployments?service=${service}&limit=${limit}`); return { content: [{ type: 'text', text: JSON.stringify(deployments, null, 2), }], }; });Opakowywanie narzędzia CLI bez otwierania shella
Dział zatytułowany „Opakowywanie narzędzia CLI bez otwierania shella”import { execFile } from 'child_process';import { promisify } from 'util';
const execFileAsync = promisify(execFile);
server.registerTool( 'kubectl_get', { description: 'Get Kubernetes resources (read-only)', inputSchema: { resource: z.enum(['pods', 'services', 'deployments', 'configmaps', 'ingresses']), namespace: z.string().optional().default('default'), name: z.string().optional(), }, }, async ({ resource, namespace, name }) => { const args = ['get', resource, '-n', namespace, '-o', 'json']; if (name) args.push(name);
try { const { stdout } = await execFileAsync('kubectl', args, { timeout: 15000 }); return { content: [{ type: 'text', text: stdout, }], }; } catch (error) { return { content: [{ type: 'text', text: `kubectl error: ${error.message}`, }], isError: true, }; } });Testowanie i debugowanie serwera MCP
Dział zatytułowany „Testowanie i debugowanie serwera MCP”Interaktywne testy w MCP Inspector
Dział zatytułowany „Interaktywne testy w MCP Inspector”MCP Inspector to webowe narzędzie do testowania serwerów bez klienta AI w pętli:
npx @modelcontextprotocol/inspector node index.mjsOtwiera interfejs w przeglądarce, w którym możesz wywoływać poszczególne narzędzia, przeglądać odpowiedzi i debugować problemy, zanim cokolwiek podepniesz do edytora.
Testy automatyczne
Dział zatytułowany „Testy automatyczne”Ponieważ logika flag żyje w tools/feature-flags.mjs (wyodrębniona w kroku hello-world), możesz testować ją bezpośrednio — bez transportu, bez klienta MCP:
import { describe, it, expect } from 'vitest';
// Import the pure handler extracted in tools/feature-flags.mjsimport { getFeatureFlags } from '../tools/feature-flags.mjs';
describe('getFeatureFlags', () => { it('returns flags for the dev environment', () => { const flags = getFeatureFlags({ environment: 'dev' }); expect(flags.darkMode).toBe(true); expect(flags.betaSearch).toBe(true); });
it('returns conservative flags for production', () => { const flags = getFeatureFlags({ environment: 'production' }); expect(flags.betaSearch).toBe(false); });});Wyjście debugowe, które nie psuje protokołu
Dział zatytułowany „Wyjście debugowe, które nie psuje protokołu”Skoro stdout niesie JSON-RPC, wyjście debugowe idzie na stderr:
const DEBUG = process.env.DEBUG === 'true';
function debug(...args) { if (DEBUG) { console.error('[MCP Debug]', ...args); }}Uruchom z włączonym debugowaniem:
DEBUG=true node index.mjsWdrożenie: npm, Docker albo zdalne HTTP
Dział zatytułowany „Wdrożenie: npm, Docker albo zdalne HTTP”Pakiet npm (zalecane dla zespołów)
Dział zatytułowany „Pakiet npm (zalecane dla zespołów)”Spakuj serwer pod instalację przez npx:
{ "name": "@mycompany/mcp-internal-tools", "version": "1.0.0", "type": "module", "bin": { "mycompany-mcp": "./index.mjs" }}Opublikuj w prywatnym rejestrze npm, a następnie podłącz:
npx -y @mycompany/mcp-internal-toolsDocker (dla izolacji)
Dział zatytułowany „Docker (dla izolacji)”FROM node:22-slimWORKDIR /appCOPY package*.json ./RUN npm ci --productionCOPY . .ENTRYPOINT ["node", "index.mjs"]Zdalny serwer HTTP (dla wspólnego dostępu)
Dział zatytułowany „Zdalny serwer HTTP (dla wspólnego dostępu)”Dla serwerów, do których powinno sięgać wielu deweloperów lub pipeline’y CI, wdróż jako usługę HTTP z transportem Streamable HTTP. Klienci wskazują wtedy url zamiast uruchamiać command:
import express from 'express';import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
const app = express();app.use(express.json());
// Stateless transport: a fresh one per request, no session tracking.// For stateful sessions, pass sessionIdGenerator: () => randomUUID() instead.app.post('/mcp', async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on('close', () => transport.close()); await server.connect(transport); await transport.handleRequest(req, res, req.body);});
app.listen(3000);Gdy własny serwer MCP przestaje działać
Dział zatytułowany „Gdy własny serwer MCP przestaje działać”Klient pokazuje serwer jako „failed” albo łączy się bez żadnych narzędzi. Niemal zawsze to problem ze stdout: każdy console.log, baner startowy czy zależność drukująca na stdout uszkadza strumień JSON-RPC na transporcie stdio. Przenieś całe logowanie do console.error i przebuduj. Jeśli stdout jest już czysty, sprawdź, czy każde wywołanie server.registerTool() odbywa się przed server.connect(transport).
Błędy parsowania JSON-RPC albo narzędzia, które nigdy się nie pojawiają mimo udanego połączenia. 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.
Błędy „Cannot find module”. Upewnij się, że "type": "module" jest w package.json, gdy używasz składni modułów ES, albo użyj rozszerzenia .mjs.
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.
„Transport mismatch” albo 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 Streamable HTTP i wskazujesz klientowi url. Nie wstawiaj klucza transport do TOML Codeksa — jest wnioskowany.
AI nie może znaleźć serwera albo serwer zawodzi przy starcie. Klienci MCP uruchamiają Twój serwer z własnego katalogu roboczego, a nie z korzenia projektu. Użyj ścieżki bezwzględnej do dist/server.js (lub index.mjs) w każdej konfiguracji.
Serwer crashuje przy pierwszym wywołaniu narzędzia. Sprawdź, czy funkcje asynchroniczne prawidłowo awaitują swoje promises. Nieobsłużone odrzucenia promise’ów powodują ciche crashe serwera.
Timeout przy długich operacjach. Klienci MCP mają domyślne timeouty, zwykle 30-60 sekund. W przypadku długotrwałych operacji najpierw zwróć komunikat o postępie, a potem końcowy wynik.