Własny serwer MCP zbudowany przez agenta Cursora
ENWłasny serwer MCP zamienia wewnętrzny system, na przykład bazę Postgres, w typowane narzędzia, które agent Cursora wywołuje zamiast zgadywać na podstawie wklejonego schematu. Agent Cursora pisze serwer na SDK TypeScript v2 na podstawie krótkiej specyfikacji, a MCP Inspector i testy w pamięci dowodzą, że działa, zanim trafi do mcp.json. Tryb tylko do odczytu wymusza rola w bazie, nie regex.
Ta strona jest dla programistów, którzy odpowiadają za wewnętrzne źródło danych, i dla tech leadów, którzy wolą jeden wspólny serwer niż osiem prywatnych obejść. Twój zespół codziennie wkleja do czatu ten sam schemat tabeli orders, a agent i tak wymyśla kolumnę shipped_date, której nie ma. Serwer, który udostępnia aktualny schemat i zabezpieczone narzędzie query, przerywa tę pętlę: agent może go napisać, a o jego poprawności rozstrzygają testy.
Sprawdzone 2026-09-26 w npm (@modelcontextprotocol/server i @modelcontextprotocol/client 2.x, @modelcontextprotocol/inspector 2.8.0), w Claude Code 2.1.283 (kanał latest) i codex-cli 0.157.1. Każdy blok kodu z tej strony został uruchomiony na Postgresie 16. Tego dnia cursor.com był nieosiągalny, więc klucze konfiguracji Cursora pochodzą ze schematu serwera MCP, który waliduje oficjalny pakiet Cursora @cursor/sdk 1.0.32, a dwie lokalizacje pliku ze strony konfiguracja MCP w Cursorze.
Co daje własny serwer MCP zbudowany w Cursorze
Dział zatytułowany „Co daje własny serwer MCP zbudowany w Cursorze”- Tabelę decyzyjną: kiedy budować serwer, a kiedy wystarczy gotowy serwer albo skill
- Specyfikację z kryteriami akceptacji i gotowy prompt, z którym agent Cursora buduje serwer na SDK v2
- Projekt tylko do odczytu, w którym to sam Postgres odrzuca zapisy, więc sprytne zapytanie nie obejdzie zabezpieczenia
- Bramkę weryfikacyjną, która nie wymaga czytania wygenerowanego kodu: wywołania Inspectorem w obu erach protokołu (które dowodzą też kryterium dotyczącego stdout) i trzy przypadki w Vitest
- Wpis
mcp.jsondla lokalnego serwera stdio i dla wspólnego serwera Streamable HTTP - Typowe awarie wraz ze sposobem wyjścia z każdej z nich
Czy potrzebujesz własnego serwera MCP?
Dział zatytułowany „Czy potrzebujesz własnego serwera MCP?”Buduj tylko ten interfejs, którego nikt inny nie dostarcza. Najpierw przejrzyj katalog, bo utrzymywany przez kogoś serwer jest lepszy od takiego, który musisz łatać sam.
| Sytuacja | Po co sięgnąć | Dlaczego |
|---|---|---|
| Zwykły Postgres, MySQL lub SQLite bez reguł biznesowych | Gotowy serwer z serwerów MCP dla baz SQL i NoSQL | Aktualizacje protokołu utrzymuje ktoś inny |
| Agent potrzebuje procedury, a nie połączenia („jak piszemy migracje”) | Skill albo reguła Cursora, zobacz własne reguły i szablony | Nie ma procesu do uruchamiania ani czego zabezpieczać |
| Wewnętrzne API, baza z regułami dostępu do wierszy albo system bez publicznego serwera | Własny serwer (ta strona) | Tylko ty znasz kontrakt |
| Ten sam serwer dla wszystkich zespołów w firmie | Własny serwer przez HTTP, dopuszczany centralnie | Regułę „budować czy kupić” opisuje strona wewnętrzne serwery MCP |
Jak agent Cursora buduje serwer ze specyfikacji
Dział zatytułowany „Jak agent Cursora buduje serwer ze specyfikacji”Agent pisze kod protokołu; ty piszesz kontrakt i kontrole. O poprawności wyniku decydują dwie rzeczy: agent czyta SDK, które faktycznie zainstalowałeś, a specyfikacja mówi, co znaczy „gotowe”, w sposób, który da się sprawdzić testem.
-
Utwórz projekt i zainstaluj serwerowe SDK v2, Zod, sterownik
postgresi narzędzia testowe. SDK v2 zastępuje pojedynczy pakiet@modelcontextprotocol/sdkosobnymi pakietami dla serwera i klienta.Okno terminala mkdir pg-mcp && cd pg-mcpnpm init -y && npm pkg set type=modulenpm install @modelcontextprotocol/server zod postgresnpm install -D @modelcontextprotocol/client tsx vitest typescript @types/node -
Napisz
spec.md. Kryteria akceptacji staną się nazwami testów w kroku weryfikacji, więc formułuj je jako obserwowalne zachowanie.# Spec: pg-mcp- Stdio server built with @modelcontextprotocol/server 2.x, served with serveStdio- Server construction lives in src/server.ts as buildServer(sql); src/index.ts only wires stdio- Reads DATABASE_URL from the environment and exits with a message on stderr if it is missing- Tool `query`: one SQL statement, at most 2000 characters, runs inside a READ ONLYtransaction, returns at most 200 rows as JSON text, returns isError on any failure- Resource template pg://table/{name}: columns, types and nullability of one public table## Acceptance criteria- `select 1 as one` returns [{"one":1}]- A write hidden in a CTE (`with d as (delete ...) select ...`) returns isError and deletes nothing- `select 1; drop table orders` is rejected before it reaches the database- Nothing is ever written to stdout except protocol messages -
Daj agentowi specyfikację i zainstalowane SDK jako jedyne źródła. Częstą przyczyną zepsutego serwera jest agent, który pisze z pamięci kod v1, choć zainstalowany jest pakiet v2.
Agent powinien dojść do czegoś bardzo podobnego. Porównuj kształt, a nie każdą linię: jedna fabryka, wejście jako z.object, transakcja tylko do odczytu i isError zamiast rzuconego wyjątku.
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';import type { Sql } from 'postgres';import { z } from 'zod';
const MAX_ROWS = 200;
export function buildServer(sql: Sql) { const server = new McpServer({ name: 'pg-mcp', version: '1.0.0' });
server.registerTool( 'query', { title: 'Run a read-only SQL query', description: `Run one SELECT against the app database. Returns at most ${MAX_ROWS} rows.`, inputSchema: z.object({ statement: z .string() .min(1) .max(2000) .refine((s) => !/;\s*\S/.test(s), 'Send exactly one statement, without a second one after a semicolon.'), }), annotations: { readOnlyHint: true }, }, async ({ statement }) => { try { // The database, not a regex, enforces read-only: writes fail inside this transaction. const rows = await sql.begin('read only', (tx) => tx.unsafe(statement)); const shown = rows.slice(0, MAX_ROWS); const note = rows.length > MAX_ROWS ? `\n(${rows.length - MAX_ROWS} more rows cut; add LIMIT or a WHERE clause)` : ''; return { content: [{ type: 'text', text: JSON.stringify(shown, null, 2) + note }] }; } catch (err) { return { content: [{ type: 'text', text: `Query failed: ${(err as Error).message}` }], isError: true }; } }, );
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' order by table_name`; return { resources: tables.map((t) => ({ uri: `pg://table/${t.table_name}`, name: t.table_name })) }; }, }), { title: 'Database tables', description: 'Columns and types for one table', mimeType: 'application/json' }, async (uri, { name }) => { const cols = await sql` select column_name, data_type, is_nullable from information_schema.columns where table_schema = 'public' and table_name = ${String(name)} order by ordinal_position`; return { contents: [{ uri: uri.href, text: JSON.stringify(cols, null, 2) }] }; }, );
return server;}import postgres from 'postgres';import { serveStdio } from '@modelcontextprotocol/server/stdio';import { buildServer } from './server.js';
if (!process.env.DATABASE_URL) { console.error('pg-mcp: DATABASE_URL is not set'); process.exit(1);}
const sql = postgres(process.env.DATABASE_URL, { max: 2, idle_timeout: 30 });serveStdio(() => buildServer(sql));serveStdio przyjmuje fabrykę, a nie gotowy serwer, bo obsługuje obie ery protokołu: klient, który zaczyna od handshake’u initialize z ery 2025, i klient mówiący MCP 2026-07-28 dostają te same narzędzia. To ważne, bo klienci się różnią. Claude Code domyślnie negocjuje 2026-07-28 z bezpośrednimi serwerami HTTP (od v2.1.274), a od v2.1.292 także z lokalnymi serwerami stdio (2026-10-12 tylko kanał latest); wyłączysz to przez MCP_PROTOCOL_NEGOTIATION=legacy. Codex 0.157.1 wciąż używa starszego protokołu (mcp_2026_07_28 jest wyłączone), a tego, którą rewizję negocjuje Cursor, nie udało się zweryfikować 2026-09-26. Migrację istniejącego serwera v1 opisuje strona MCP 2026-07-28: co się zmieniło.
Dlaczego o trybie tylko do odczytu decyduje rola w bazie, a nie regex
Dział zatytułowany „Dlaczego o trybie tylko do odczytu decyduje rola w bazie, a nie regex”Filtr słów kluczowych w stylu „odrzuć DELETE” przepuści with d as (delete from orders returning *) select count(*) from d, a agent pisze takie CTE. Pierwszym prawdziwym zabezpieczeniem jest transakcja read only w handlerze. Drugim jest rola, która w ogóle nie może zapisywać. Uruchom to raz jako właściciel tabel, a potem ustaw hasło poleceniem \password mcp_reader w psql:
CREATE ROLE mcp_reader LOGIN;GRANT CONNECT ON DATABASE shop TO mcp_reader;GRANT USAGE ON SCHEMA public TO mcp_reader;GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_reader;ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_reader;ALTER ROLE mcp_reader SET default_transaction_read_only = on;ALTER ROLE mcp_reader SET statement_timeout = '5s';Skieruj DATABASE_URL na replikę do odczytu albo kopię stagingową z tą rolą. Sprawdzenie średnika w schemacie Zod to udogodnienie, które daje agentowi czytelny komunikat; nie jest granicą bezpieczeństwa.
Zweryfikuj serwer, zanim zobaczy go Cursor
Dział zatytułowany „Zweryfikuj serwer, zanim zobaczy go Cursor”Niczego nie rejestruj, dopóki serwer nie przejdzie dwóch bramek. Żadna z nich nie wymaga czytania wygenerowanego kodu linia po linii.
Bramka 1: wywołaj serwer ręcznie przez Inspector CLI. CLI łączy się w erze legacy, jeśli nie wskażesz inaczej, więc listę narzędzi pobierz w obu erach. Najpierw podajesz polecenie serwera, potem opcje:
# DATABASE_URL points at the mcp_reader role, loaded from your .envset -a; . ./.env; set +anpx @modelcontextprotocol/inspector --cli npx tsx src/index.ts -e "DATABASE_URL=$DATABASE_URL" --protocol-era modern --method tools/listnpx @modelcontextprotocol/inspector --cli npx tsx src/index.ts -e "DATABASE_URL=$DATABASE_URL" --protocol-era legacy --method tools/listnpx @modelcontextprotocol/inspector --cli npx tsx src/index.ts -e "DATABASE_URL=$DATABASE_URL" --protocol-era modern --method resources/read --uri pg://table/ordersTrzymaj URL w .env (w .gitignore) albo w menedżerze sekretów: nigdy nie wpisuj go dosłownie w polecenie i nigdy go nie commituj. Powłoka rozwija "$DATABASE_URL" w cudzysłowie, więc hasło i tak trafia do argumentów procesu; uruchamiaj te testy z rolą tylko do odczytu mcp_reader, a nie z kontem administratora. Bez --cli ten sam pakiet otwiera interfejs webowy Inspectora, w którym widać surową wymianę JSON-RPC.
Udane wywołanie Inspectora dowodzi też kryterium, że na stdout trafiają wyłącznie komunikaty protokołu: każda zabłąkana linia na stdout psuje parsowanie JSON-RPC i polecenie kończy się błędem.
Bramka 2: zamień kryteria akceptacji w testy. Klient v2 ma InMemoryTransport, więc prawdziwy Client rozmawia z twoim serwerem bez uruchamiania osobnego procesu. Uruchamiaj zestaw na bazie jednorazowej, nigdy na produkcji:
import { afterAll, describe, expect, it } from 'vitest';import postgres from 'postgres';import { Client, InMemoryTransport } from '@modelcontextprotocol/client';import { buildServer } from '../src/server.js';
const sql = postgres(process.env.TEST_DATABASE_URL!);afterAll(() => sql.end());
async function connect() { const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: 'test', version: '1.0.0' }); await Promise.all([buildServer(sql).connect(serverSide), client.connect(clientSide)]); return client;}
describe('pg-mcp', () => { it('answers a SELECT', async () => { const client = await connect(); const res = await client.callTool({ name: 'query', arguments: { statement: 'select 1 as one' } }); expect(res.isError).toBeFalsy(); const [first] = res.content as Array<{ type: 'text'; text: string }>; expect(JSON.parse(first.text)).toEqual([{ one: 1 }]); });
it('refuses a write hidden in a CTE and deletes nothing', async () => { const client = await connect(); const [{ count: before }] = await sql`select count(*)::int as count from orders`; const res = await client.callTool({ name: 'query', arguments: { statement: 'with d as (delete from orders returning *) select count(*) from d' }, }); expect(res.isError).toBe(true); expect(JSON.stringify(res.content)).toMatch(/read-only transaction/); const [{ count: after }] = await sql`select count(*)::int as count from orders`; expect(after).toBe(before); });
it('refuses stacked statements', async () => { const client = await connect(); const res = await client.callTool({ name: 'query', arguments: { statement: 'select 1; drop table orders' } }); expect(res.isError).toBe(true); });});# TEST_DATABASE_URL points at a disposable database seeded with an orders tablenpx vitest runNajważniejszy jest test z CTE: nie przejdzie na żadnej wersji serwera, która opiera się na filtrowaniu słów kluczowych. Dla każdego nowego narzędzia najpierw dodaj test, dopiero potem samo narzędzie, a npx tsc --noEmit i npx vitest run wstaw do CI, żeby ponownie wygenerowany handler nie trafił do gałęzi bez testów. Właściciel bazy zatwierdza uprawnienia roli; recenzent pull requesta zatwierdza zielone testy i wynik Inspectora, a nie kod źródłowy handlera.
Zarejestruj serwer w mcp.json Cursora
Dział zatytułowany „Zarejestruj serwer w mcp.json Cursora”Cursor czyta mcpServers z ~/.cursor/mcp.json (dla wszystkich projektów) i z .cursor/mcp.json w projekcie. Wpis z command uruchamia lokalny proces stdio; wpis z url łączy się ze zdalnym serwerem. Wartości obsługują interpolację ${env:NAZWA}, więc sam plik nie zawiera sekretów.
{ "mcpServers": { "pg-mcp": { "command": "npx", "args": ["tsx", "/ABSOLUTE/PATH/TO/pg-mcp/src/index.ts"], "env": { "DATABASE_URL": "${env:PG_MCP_DATABASE_URL}" } } }}Zastąp /ABSOLUTE/PATH/TO/pg-mcp bezwzględną ścieżką do swojej kopii repozytorium.
Ten sam schemat przyjmuje cwd, envFile (plik dotenv, przydatny, gdy Cursor startuje z Docka i nie dziedziczy zmiennych twojej powłoki) oraz enabledTools (listę, która ogranicza narzędzia widoczne dla agenta). Prywatny URL bazy trzymaj w pliku globalnym; .cursor/mcp.json commituj tylko wtedy, gdy każda wartość jest odwołaniem ${env:...}.
Otwórz sekcję MCP w ustawieniach Cursora (dokładne miejsce opisuje strona konfiguracja MCP w Cursorze) i sprawdź, czy pg-mcp jest włączony i pokazuje narzędzie query. Potem zadaj agentowi pytanie, na które sam schemat nie odpowie, na przykład „Ile zamówień wysłaliśmy w ostatnich siedmiu dniach?”. Działająca konfiguracja czyta pg://table/orders albo wywołuje query na prawdziwych kolumnach zamiast zgadywać. Jeśli odpowiedź cytuje kolumnę, której nie ma, agent nie użył serwera; zajrzyj do sekcji o awariach poniżej.
Sam serwer nie zależy od klienta. Żeby koledzy korzystający z innych narzędzi mieli ten sam serwer, zarejestruj tam to samo polecenie: claude mcp add pg-mcp -e DATABASE_URL="$PG_MCP_DATABASE_URL" -- npx tsx /ABSOLUTE/PATH/TO/pg-mcp/src/index.ts w Claude Code oraz codex mcp add pg-mcp --env DATABASE_URL="$PG_MCP_DATABASE_URL" -- npx tsx /ABSOLUTE/PATH/TO/pg-mcp/src/index.ts w Codeksie. Oba polecenia zapisują rozwiniętą wartość, razem z hasłem, w konfiguracji użytkownika danego narzędzia (~/.claude.json i ~/.codex/config.toml), więc rejestruj je wyłącznie z URL-em roli tylko do odczytu mcp_reader. Zakresy i sekrety w tych narzędziach opisują strony konfiguracja MCP w Claude Code i konfiguracja MCP w Codeksie.
Udostępnij serwer zespołowi przez Streamable HTTP
Dział zatytułowany „Udostępnij serwer zespołowi przez Streamable HTTP”Stdio wystarcza jednemu programiście. Gdy zespół potrzebuje jednego zestawu poświadczeń, jednego dziennika audytu i jednego miejsca wdrażania poprawek, uruchom tę samą fabrykę przez Streamable HTTP. Nie buduj nowego serwera na transporcie SSE: specyfikacja MCP oznaczyła go jako przestarzały.
Wynik powinien wyglądać tak i jest na tyle krótki, że da się go przejrzeć w całości:
import { createServer } from 'node:http';import { timingSafeEqual } from 'node:crypto';import postgres from 'postgres';import { createMcpHandler } from '@modelcontextprotocol/server';import { toNodeHandler } from '@modelcontextprotocol/node';import { buildServer } from './server.js';
const { DATABASE_URL, MCP_TOKEN, PORT = '3000' } = process.env;if (!DATABASE_URL || !MCP_TOKEN) { console.error('pg-mcp: DATABASE_URL and MCP_TOKEN must be set'); process.exit(1);}
const sql = postgres(DATABASE_URL, { max: 5 });const mcp = toNodeHandler(createMcpHandler(() => buildServer(sql)));const expected = Buffer.from(`Bearer ${MCP_TOKEN}`);
createServer((req, res) => { const given = Buffer.from(req.headers.authorization ?? ''); if (given.length !== expected.length || !timingSafeEqual(given, expected)) { res.writeHead(401).end(); return; } const path = new URL(req.url ?? '/', 'http://localhost').pathname; if (path !== '/mcp' && !path.startsWith('/mcp/')) { res.writeHead(404).end(); return; } void mcp(req, res);}).listen(Number(PORT));Uruchom go za TLS (reverse proxy albo ingress twojej platformy) i sprawdź Inspectorem, zanim ktokolwiek się połączy:
npx @modelcontextprotocol/inspector --cli https://mcp.example.com/mcp --transport http --header "Authorization: Bearer $MCP_TOKEN" --protocol-era modern --method tools/listW Cursorze zdalny wpis używa url i headers zamiast command i args:
{ "mcpServers": { "pg-mcp": { "url": "https://mcp.example.com/mcp", "headers": { "Authorization": "Bearer ${env:PG_MCP_TOKEN}" } } }}Wspólny token bearer to minimum. Jeśli potrzebujesz tożsamości per użytkownik i możliwości odbierania dostępu, przejdź na OAuth (schemat Cursora ma blok auth z CLIENT_ID, CLIENT_SECRET i scopes) i dopuszczaj serwer centralnie, jak opisuje strona rejestry i bramki MCP. Model zagrożeń omawia zabezpieczanie serwerów MCP.
Kiedy własny serwer MCP psuje się w Cursorze
Dział zatytułowany „Kiedy własny serwer MCP psuje się w Cursorze”| Objaw | Prawdopodobna przyczyna | Co zrobić |
|---|---|---|
| Serwer ma status błędu i nic nie wyjaśnia dlaczego | Coś wypisało tekst na stdout (console.log, baner biblioteki) i zepsuło strumień JSON-RPC | Loguj wyłącznie przez console.error. Uruchom polecenie Inspector CLI z kroku weryfikacji; wyłoży się na tym samym strumieniu |
| Serwer się łączy, ale nie pokazuje narzędzi | Proces padł przy starcie: brak DATABASE_URL, złe hasło albo wyjątek przed serveStdio | Uruchom npx tsx src/index.ts w terminalu z tym samym środowiskiem i przeczytaj stderr. Sprawdź, czy zmienna z ${env:...} istnieje w środowisku, z którego wystartował Cursor, albo użyj envFile |
Importy z @modelcontextprotocol/sdk/server/stdio.js lub .../mcp.js albo wywołania server.tool(...) | Agent napisał kod v1 z pamięci | Uruchom ponownie prompt budujący; przypina agenta do zainstalowanych plików .d.mts. Stary serwer przenieś według migracji do 2026-07-28 |
Agent wywołuje query i „narzędzie nic nie zrobiło” | Walidacja refine w schemacie Zod odrzuciła wejście z niejasnym komunikatem | Każdy komunikat refine niech mówi, co wysłać zamiast tego, tak jak sprawdzenie średnika |
| Agent zawiesza się na dużym wyniku | Zapytanie zwróciło tysiące wierszy | Zostaw limit wierszy, w opisie narzędzia każ agentowi dodawać LIMIT, a wolne skany niech ucina statement_timeout |
Zdalny serwer „niedostępny” w Cursorze, choć odpowiada na curl | 401 z nagłówka, którego zmienna ${env:...} jest pusta w środowisku Cursora | Uruchom Inspector CLI z tym samym nagłówkiem; jeśli przechodzi, popraw zmienną widoczną dla Cursora i zrestartuj wpis serwera |
| Narzędzia widać w jednym kliencie, a w drugim nie | Klienci negocjują różne ery protokołu, a serwer podłączono ręcznie tylko do jednej | Serwuj przez serveStdio albo createMcpHandler i uruchamiaj Inspector z obiema wartościami --protocol-era |
Dokąd dalej z własnymi serwerami MCP
Dział zatytułowany „Dokąd dalej z własnymi serwerami MCP”- Wymaganie wstępne: konfiguracja MCP w Cursorze opisuje dodawanie gotowych serwerów i rozwiązywanie problemów z nimi.
- Budowanie własnego serwera MCP to przewodnik dla wszystkich narzędzi: prompty, opakowania REST i CLI, pakowanie jako npm albo Docker.
- Obniżanie kosztu tokenów MCP wyjaśnia, jak liczba narzędzi i rozmiar wyników zjadają okno kontekstu.
- Przepływy automatyzacji w Cursorze łączą nowe narzędzia w powtarzalne zadania, a Cursor SDK steruje agentem z kodu.
- Prywatność i bezpieczeństwo w Cursorze określa, do czego agent ma dostęp, a problemy z połączeniem serwera MCP omawia awarie dokładniej.