Przejdź do głównej zawartości

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.

  • Działający szkielet serwera na @modelcontextprotocol/sdk i Zod, rejestrujący prawdziwe narzędzie z walidacją danych wejściowych
  • Aktualne API server.registerTool() i powód, dla którego stary kształt server.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

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.

  1. Zainicjuj projekt.

    Okno terminala
    mkdir my-mcp-server && cd my-mcp-server
    npm init -y
    npm install @modelcontextprotocol/sdk zod
  2. Utwórz serwer. Dodaj to do index.mjs:

    #!/usr/bin/env node
    import { 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];
    }
  3. Nadaj uprawnienia do wykonania.

    Okno terminala
    chmod +x index.mjs
  4. Podłącz go do swojego edytora, korzystając z konfiguracji poniżej.

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:

Okno terminala
npm install @modelcontextprotocol/sdk zod turndown
npm install -D typescript @types/node @types/turndown
npx tsc --init
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);

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:

Okno terminala
npx tsc

To 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"]
}
}
}

Zrestartuj klienta, a potem poproś go o wywołanie jednego z Twoich narzędzi, żeby potwierdzić, że wszystko jest podpięte.

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 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 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 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.`,
},
}],
};
}
);

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.

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),
}],
};
}
);
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,
};
}
}
);

MCP Inspector to webowe narzędzie do testowania serwerów bez klienta AI w pętli:

Okno terminala
npx @modelcontextprotocol/inspector node index.mjs

Otwiera 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.

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:

test/feature-flags.test.mjs
import { describe, it, expect } from 'vitest';
// Import the pure handler extracted in tools/feature-flags.mjs
import { 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);
});
});

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:

Okno terminala
DEBUG=true node index.mjs

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:

Okno terminala
npx -y @mycompany/mcp-internal-tools
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
ENTRYPOINT ["node", "index.mjs"]

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);

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.