Przejdź do głównej zawartości

Budowanie niestandardowych serwerów MCP: rozszerzaj Cursor o swoje narzędzia

Twój zespół wciąż wkleja ten sam schemat Postgresa do czatu, a Cursor i tak w połowie przypadków źle zgaduje typy kolumn. Dane siedzą za wewnętrznym API, którego model nigdy nie widział, więc każde „napisz mi zapytanie” zamienia się w trzy rundy poprawek. Niestandardowy serwer MCP rozwiązuje to na stałe: udostępniasz swoją bazę danych (albo dowolne wewnętrzne narzędzie) jako pełnoprawne narzędzia, które agent może wywołać, i Cursor przestaje zgadywać.

Dobra wiadomość jest taka, że serwera nie musisz pisać ręcznie. Najszybsza droga to pozwolić agentowi Cursor zbudować go na podstawie krótkiej specyfikacji i oficjalnej dokumentacji SDK, a następnie zweryfikować całość w MCP Inspector, zanim w ogóle podłączysz go do edytora. To właśnie ten przepływ pracy omawia ten przewodnik.

  • Powtarzalny przepływ pracy z agentem Cursor do generowania serwera MCP z pliku spec.md oraz dokumentacji SDK wskazanej przez @
  • Działający, tylko-do-odczytu serwer MCP dla Postgresa zbudowany na aktualnym @modelcontextprotocol/sdk (1.29.x)
  • Gotowe do wklejenia prompty, które wygenerują serwer, utwardzą narzędzie walidacją i przekonwertują stdio na zdalny Streamable HTTP
  • Dokładny wpis w ~/.cursor/mcp.json (z wymaganym polem type: "stdio"), aby go zarejestrować
  • Prawdziwą listę „kiedy to się psuje” dla awarii, które naprawdę bolą: zaśmiecanie stdout, uwierzytelnianie i odrzucenia schematu

Model Context Protocol to standard klient/serwer. Cursor jest klientem; twój serwer ogłasza narzędzia (wywoływalne funkcje jak query), zasoby (przeglądalne dane jak listy tabel) i komunikuje się przez stdio w przypadku serwerów lokalnych lub Streamable HTTP w przypadku zdalnych. Praktycznie nigdy nie piszesz instalacji protokołu ręcznie — robi to SDK, a agent Cursor podłącza SDK do twoich danych.

Krok 1: napisz specyfikację, którą agent może realizować

Dział zatytułowany „Krok 1: napisz specyfikację, którą agent może realizować”
  1. Utwórz projekt i zainstaluj dwie potrzebne zależności:

    Okno terminala
    mkdir pg-mcp && cd pg-mcp
    npm init -y
    npm install @modelcontextprotocol/sdk zod postgres
  2. Napisz plik spec.md obok swojego kodu. Trzymaj go lekkim — kilka punktów w zupełności wystarczy agentowi do wygenerowania kodu:

    # Spec: Postgres MCP server
    - Read DATABASE_URL from the MCP env config
    - Expose a `query` tool that runs read-only SQL
    - Reject DROP / DELETE / TRUNCATE / UPDATE / INSERT unless
    DANGEROUSLY_ALLOW_WRITE_OPS=1
    - Expose tables as resources (one per table) so the agent can browse the schema
    - Use Zod for all tool input schemas
    - Transport: stdio

Najważniejszy ruch: podaj agentowi aktualny plik README SDK jako kontekst, żeby nie wygenerował API sprzed wersji 1.x ze swoich danych treningowych. Własny cookbook MCP Cursora robi dokładnie to — wskazuje przez @ surowe pliki README SDK i bibliotek w promcie.

Po rundzie czy dwóch wymiany zdań powinieneś wylądować na czymś zbliżonym do tego. Zwróć uwagę na kształt: McpServer + registerTool, schemat Zod, pojedynczy zwracany CallToolResult oraz StdioServerTransport do uruchomienia serwera.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL!);
const allowWrites = process.env.DANGEROUSLY_ALLOW_WRITE_OPS === '1';
const server = new McpServer({ name: 'pg-mcp', version: '1.0.0' });
server.registerTool(
'query',
{
title: 'Run SQL query',
description: 'Execute a read-only SQL query against the database',
inputSchema: {
statement: z
.string()
.min(1)
.refine(
(s) => allowWrites || !/\b(drop|delete|truncate|update|insert)\b/i.test(s),
'Write operations are disabled. Set DANGEROUSLY_ALLOW_WRITE_OPS=1 to enable.',
),
},
},
async ({ statement }) => {
const rows = await sql.unsafe(statement);
return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
},
);
const transport = new StdioServerTransport();
await server.connect(transport);

Kluczowy szczegół poprawności: callback narzędzia zwraca jeden CallToolResult (tablicę content), a nie asynchroniczny generator. Jeśli chcesz raportować postęp długiego zadania, SDK udostępnia powiadomienia przez parametr extra handlera — a nie przez yield.

Zasoby pozwalają agentowi przeglądać twój schemat bez zużywania wywołania narzędzia. Użyj registerResource z ResourceTemplate, aby URI był dynamiczny:

import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
server.registerResource(
'table',
new ResourceTemplate('pg://table/{name}', {
list: async () => {
const tables = await sql`
select table_name from information_schema.tables
where table_schema = 'public'`;
return {
resources: tables.map((t) => ({
uri: `pg://table/${t.table_name}`,
name: t.table_name,
})),
};
},
}),
{ title: 'Database tables', description: 'Browse columns for each table' },
async (uri, { name }) => {
const cols = await sql`
select column_name, data_type from information_schema.columns
where table_name = ${name}`;
return { contents: [{ uri: uri.href, text: JSON.stringify(cols, null, 2) }] };
},
);

Krok 4: zweryfikuj w MCP Inspector, zanim ruszysz Cursor

Dział zatytułowany „Krok 4: zweryfikuj w MCP Inspector, zanim ruszysz Cursor”

Nie rejestruj nieprzetestowanego serwera w edytorze — najpierw zdebuguj go w izolacji. Inspector to przeglądarkowe narzędzie, które uruchamiasz za pomocą npx; nie ma instalacji globalnej ani REPL-a.

Okno terminala
npx @modelcontextprotocol/inspector npx tsx src/index.ts

Otwiera to lokalny interfejs WWW, w którym możesz wylistować narzędzia i zasoby serwera, odpalić query z prawdziwym zapytaniem i obserwować surową wymianę JSON-RPC. Jeśli narzędzie rzuca wyjątek albo twój schemat odrzuca wejście, zobaczysz to tutaj — na długo przed agentem Cursora.

Dodaj serwer do ~/.cursor/mcp.json (globalnie) lub .cursor/mcp.json (w zakresie projektu). W przypadku serwerów stdio Cursor wymaga teraz pola type:

{
"mcpServers": {
"pg-mcp": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "/abs/path/to/pg-mcp/src/index.ts"],
"env": {
"DATABASE_URL": "${env:DATABASE_URL}"
}
}
}
}

Otwórz Cursor Settings -> MCP / Tools & Integrations, aby potwierdzić, że serwer się połączył i jego narzędzia są na liście (zielona kropka). Teraz zapytaj agenta „ile zamówień wysłano w zeszłym tygodniu?”, a wywoła on query na twoim prawdziwym schemacie zamiast halucynować kolumny.

Działanie przez stdio jest idealne dla jednego programisty. Gdy całemu zespołowi potrzebne jest to samo połączenie — wspólne poświadczenia, centralne ograniczanie liczby żądań, jedno miejsce do aktualizacji schematu — wdrażasz serwer jako usługę HTTP. Nowoczesny transport to Streamable HTTP na pojedynczym endpoincie /mcp. Stary transport HTTP+SSE został wycofany w specyfikacji MCP na rzecz Streamable HTTP, a poszczególni dostawcy wygaszają swoje endpointy /sse według własnych harmonogramów (serwer Rovo od Atlassiana na przykład wyłącza /v1/sse 30 czerwca 2026), więc nie buduj nowych serwerów na /sse.

Wynikowa konfiguracja Cursora porzuca command/args i używa url plus obiektu headers — dla serwerów zdalnych nie ma klucza transport:

{
"mcpServers": {
"pg-mcp": {
"url": "https://mcp.company.com/mcp",
"headers": {
"Authorization": "Bearer ${env:MCP_TOKEN}"
}
}
}
}

W przypadku dystrybucji wersji stdio do zespołu opublikuj ją jako pakiet npm z wpisem bin i wskaż command na binarkę (npx @company/pg-mcp) zamiast na ścieżkę bezwzględną.

Nie potrzebujesz biblioteki z atrapą klienta — nie ma pakietu @mcp/testing. Testuj handlery narzędzi na dwa sposoby. Najtańszy to wywołać logikę handlera bezpośrednio w Vitest. Do sprawdzenia end-to-end SDK dostarcza parę transportów działających w pamięci, dzięki czemu możesz połączyć prawdziwego Client z twoim McpServer bez uruchamiania procesu:

import { describe, it, expect } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { server } from '../src/index.js'; // export your McpServer
describe('pg-mcp', () => {
it('rejects write operations by default', async () => {
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'test', version: '1.0.0' });
await Promise.all([server.connect(serverT), client.connect(clientT)]);
const res = await client.callTool({
name: 'query',
arguments: { statement: 'DROP TABLE users' },
});
expect(res.isError).toBe(true);
});
});

Serwery MCP psują się na kilka przewidywalnych sposobów. Oto te, które kosztują najwięcej czasu:

  • Zaśmiecanie stdout zabija połączenie. Serwer stdio mówi po JSON-RPC przez stdout. Każdy zabłąkany console.log uszkadza strumień, a Cursor zgłasza serwer jako uszkodzony bez żadnego użytecznego błędu. Kieruj całe logowanie na stderr (console.error) albo do pliku, nigdy na console.log.
  • Serwer się łączy, ale nie pojawiają się żadne narzędzia. Niemal zawsze jest to awaria podczas startu — brakujący DATABASE_URL, nieobsłużona obietnica albo rzucony wyjątek przed server.connect(). Uruchom go samodzielnie w Inspectorze (Krok 4), żeby zobaczyć prawdziwy ślad stosu; Cursor go połyka.
  • Wywołania narzędzi przekraczają timeout przy dużych wynikach. Zwrócenie zapytania na 50 tys. wierszy jako jednego bloku tekstu zawiesza agenta. Dodaj LIMIT w narzędziu albo stronicuj. Długotrwała praca powinna raportować postęp przez parametr extra handlera, a nie blokować.
  • Odrzucenia walidacji schematu wyglądają jak „narzędzie nic nie zrobiło”. Gdy Zod odrzuca wejście, agent dostaje błąd walidacji, a nie wynik. Spraw, by komunikaty .refine() były jednoznaczne („Write operations are disabled…”), żeby agent sam się poprawił, zamiast ślepo ponawiać próbę.
  • Awarie uwierzytelniania na serwerach zdalnych objawiają się jako ciche rozłączenie. Błąd 401 z twojego endpointu /mcp pokazuje się w Cursor jako „server unavailable”. Sprawdź logi serwera i potwierdź, że nagłówek Authorization w mcp.json faktycznie podstawił twoją zmienną środowiskową (${env:MCP_TOKEN} wymaga, by zmienna istniała w środowisku uruchomieniowym Cursora).