Build a custom MCP server with Cursor's agent
PLA custom MCP server turns an internal system, such as a Postgres database, into typed tools that Cursor’s agent calls instead of guessing from pasted schemas. Cursor’s agent writes the server from a short spec on the v2 TypeScript SDK; the MCP Inspector and in-memory tests prove it before mcp.json registers it. The database role, not a regex, enforces read-only.
This page is for developers who own an internal data source and for tech leads who want one shared server instead of eight private hacks. Your team pastes the same orders schema into chat every day, and the agent still invents a shipped_date column that does not exist. A server that exposes the live schema and a guarded query tool ends that loop, and the agent can write it while the tests judge it.
Checked on 2026-09-26 against npm (@modelcontextprotocol/server and @modelcontextprotocol/client 2.x, @modelcontextprotocol/inspector 2.8.0), Claude Code 2.1.283 (the latest channel) and codex-cli 0.157.1. Every code block on this page ran against Postgres 16. cursor.com could not be reached that day, so the Cursor config keys come from the MCP server schema that Cursor’s own @cursor/sdk 1.0.32 package validates, and the two file locations come from MCP setup in Cursor.
What a custom MCP server built with Cursor gives you
Section titled “What a custom MCP server built with Cursor gives you”- A decision table for when to build a server and when an existing one or a skill is enough
- A spec with acceptance criteria and a copy-paste prompt that has Cursor’s agent build the server on the v2 SDK
- A read-only design in which Postgres itself refuses writes, so a clever query cannot get around the guard
- A verification gate you can run without reading the generated code: Inspector calls in both protocol eras (which also prove the stdout criterion) plus three Vitest cases
- The
mcp.jsonentry for a local stdio server and for a shared Streamable HTTP server - Failure modes with the recovery step for each
Do you need a custom MCP server?
Section titled “Do you need a custom MCP server?”Build one only for the interface nobody else ships. Check the catalogue first, because a maintained server beats one you have to patch.
| Situation | Reach for | Why |
|---|---|---|
| Standard Postgres, MySQL, or SQLite, no business rules | An existing server from SQL and NoSQL database MCP servers | Someone else maintains the protocol upgrades |
| The agent needs a procedure, not a connection (“how we write migrations”) | A skill or a Cursor rule, see custom rules and templates | No process to run, nothing to secure |
| An internal API, a database with row rules, or a system with no public server | A custom server (this page) | Only you know the contract |
| The same server for every team in the company | A custom server over HTTP, allowlisted centrally | See internal MCP servers for the build-or-buy rule |
How Cursor’s agent builds the server from a spec
Section titled “How Cursor’s agent builds the server from a spec”The agent writes the protocol code; you write the contract and the checks. Two things decide whether the result is right: the agent reads the SDK you actually installed, and the spec states what “done” means in terms a test can check.
-
Create the project and install the v2 server SDK, Zod, the
postgresdriver, and the test tools. The v2 SDK replaces the single@modelcontextprotocol/sdkpackage with separate server and client packages.Terminal window 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 -
Write
spec.md. The acceptance criteria become the test names in the verification step, so write them as observable behaviour.# 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 -
Give the agent the spec and the installed SDK as its only sources. A common cause of a broken server is an agent writing v1 code from memory against a v2 package.
The agent should land close to this. Compare the shape, not every line: one factory, z.object input, a read-only transaction, and isError instead of a thrown exception.
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 takes a factory rather than a server because it serves both protocol eras: a client that opens with the 2025-era initialize handshake and a client that speaks MCP 2026-07-28 both get the same tools. That matters here, because clients differ. Claude Code negotiates 2026-07-28 by default with direct HTTP servers (all installs since v2.1.274) and, from v2.1.292, with local stdio servers too (latest channel only on 2026-10-12); MCP_PROTOCOL_NEGOTIATION=legacy opts out. Codex 0.157.1 still uses the older protocol (mcp_2026_07_28 is off), and which revision Cursor negotiates was not verified on 2026-09-26. MCP 2026-07-28: what changed covers the migration for an existing v1 server.
Why the database role, not the regex, keeps the server read-only
Section titled “Why the database role, not the regex, keeps the server read-only”A keyword filter such as “reject DELETE” misses with d as (delete from orders returning *) select count(*) from d, and the agent writes CTEs like that. The read only transaction in the handler is the first real guard. The second is a role that cannot write in the first place. Run this once as the owner of the tables, then set a password with \password mcp_reader in 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';Point DATABASE_URL at a read replica or a staging copy with this role. The semicolon check in the Zod schema is a convenience that gives the agent a clear message; it is not a security boundary.
Verify the server before Cursor sees it
Section titled “Verify the server before Cursor sees it”Register nothing until the server passes two gates. Neither requires you to read the generated code line by line.
Gate 1: call it by hand with the Inspector CLI. The CLI connects on the legacy protocol era unless you say otherwise, so run the list in both eras. The server command comes first, then the options:
# 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/ordersKeep the URL in .env (gitignored) or a secret manager: never type it literally into a command and never commit it. The quoted "$DATABASE_URL" is expanded by the shell, so the password still reaches the process arguments; run these checks against the read-only mcp_reader role, not an admin login. Without --cli, the same package opens the Inspector’s web UI, where you can see the raw JSON-RPC exchange.
A green Inspector run also proves the criterion that nothing but protocol messages reaches stdout: any stray stdout line breaks the JSON-RPC parse, and the command fails.
Gate 2: turn the acceptance criteria into tests. The v2 client ships an InMemoryTransport, so a real Client talks to your server without spawning a process. Run the suite against a disposable database, never production:
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 runThe CTE test is the one that matters: it fails on any version of the server that relies on keyword filtering. Add a test for every new tool before the tool itself, and put npx tsc --noEmit plus npx vitest run in CI so a regenerated handler cannot merge untested. The owner of the database signs off on the role grants; the reviewer of the pull request signs off on green tests and the Inspector output, not on the handler’s source.
Register the server in Cursor’s mcp.json
Section titled “Register the server in Cursor’s mcp.json”Cursor reads mcpServers from ~/.cursor/mcp.json (every project) and from .cursor/mcp.json in the project. An entry with command runs a local stdio process; an entry with url connects to a remote server. Values support ${env:NAME} interpolation, so the file itself holds no secret.
{ "mcpServers": { "pg-mcp": { "command": "npx", "args": ["tsx", "/ABSOLUTE/PATH/TO/pg-mcp/src/index.ts"], "env": { "DATABASE_URL": "${env:PG_MCP_DATABASE_URL}" } } }}Replace /ABSOLUTE/PATH/TO/pg-mcp with the absolute path to your checkout.
The same schema accepts cwd, envFile (a dotenv file, useful when Cursor starts from the Dock and does not inherit your shell’s variables), and enabledTools (a list that limits which tools the agent sees). Put a personal database URL in the global file; commit .cursor/mcp.json only when every value is an ${env:...} reference.
Open the MCP section of Cursor’s settings (the exact location is on MCP setup in Cursor) and confirm that pg-mcp is enabled and lists query. Then ask the agent a question the schema alone cannot answer, such as “How many orders shipped in the last seven days?”. A working setup reads pg://table/orders or calls query against the real columns instead of guessing. If the answer quotes a column that does not exist, the agent did not use the server; see the failure modes below.
The server itself is client-neutral. To give colleagues on other tools the same server, register the same command there: claude mcp add pg-mcp -e DATABASE_URL="$PG_MCP_DATABASE_URL" -- npx tsx /ABSOLUTE/PATH/TO/pg-mcp/src/index.ts in Claude Code, and codex mcp add pg-mcp --env DATABASE_URL="$PG_MCP_DATABASE_URL" -- npx tsx /ABSOLUTE/PATH/TO/pg-mcp/src/index.ts in Codex. Both commands store the expanded value, password included, in the tool’s user config (~/.claude.json and ~/.codex/config.toml), so register them with the read-only mcp_reader URL only. Scopes and secrets for those tools are on MCP setup in Claude Code and MCP setup in Codex.
Share the server with your team over Streamable HTTP
Section titled “Share the server with your team over Streamable HTTP”Stdio suits one developer. When a team needs one set of credentials, one audit log, and one place to deploy fixes, run the same factory over Streamable HTTP. Do not build a new server on the SSE transport: it is deprecated in the MCP specification.
The result should look like this, and it is small enough to review in full:
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));Run it behind TLS (a reverse proxy or your platform’s ingress), and check it with the Inspector before anyone connects:
npx @modelcontextprotocol/inspector --cli https://mcp.example.com/mcp --transport http --header "Authorization: Bearer $MCP_TOKEN" --protocol-era modern --method tools/listIn Cursor, the remote entry uses url and headers instead of command and args:
{ "mcpServers": { "pg-mcp": { "url": "https://mcp.example.com/mcp", "headers": { "Authorization": "Bearer ${env:PG_MCP_TOKEN}" } } }}A shared bearer token is the minimum. For per-user identity and revocation, move to OAuth (Cursor’s schema has an auth block with CLIENT_ID, CLIENT_SECRET and scopes), and allowlist the server centrally as described in MCP registries and gateways. Securing and hardening MCP servers covers the threat model.
When a custom MCP server breaks in Cursor
Section titled “When a custom MCP server breaks in Cursor”| Symptom | Likely cause | Recovery |
|---|---|---|
| Server shows as failed and no error explains why | Something wrote to stdout (a console.log, a library banner) and corrupted the JSON-RPC stream | Log only with console.error. Run the Inspector CLI command from the verification step; it fails on the same stream |
| Server connects but lists no tools | The process crashed at startup: missing DATABASE_URL, bad password, or a throw before serveStdio | Run npx tsx src/index.ts in a terminal with the same environment and read stderr. Check that the ${env:...} variable exists in the environment Cursor started from, or use envFile |
Imports from @modelcontextprotocol/sdk/server/stdio.js or .../mcp.js, or calls to server.tool(...) | The agent wrote v1 code from memory | Rerun the build prompt; it pins the agent to the installed .d.mts files. For an old server, follow the 2026-07-28 migration |
The agent calls query and gets “tool did nothing” | A Zod refinement rejected the input and the message was vague | Make every refine message say what to send instead, as the semicolon check does |
| The agent stalls on a large result | A query returned thousands of rows | Keep the row cap, tell the agent to add LIMIT in the tool description, and rely on statement_timeout for slow scans |
Remote server “unavailable” in Cursor, but it answers curl | 401 from a header whose ${env:...} variable is empty in Cursor’s environment | Run the Inspector CLI with the same header; if it passes, fix the variable Cursor sees, then restart the server entry |
| Tools listed in one client and not another | The two clients negotiate different protocol eras and the server was hand-wired to one | Serve through serveStdio or createMcpHandler, and run the Inspector in both --protocol-era values |
Where to go next with custom MCP servers
Section titled “Where to go next with custom MCP servers”- Prerequisite: MCP setup in Cursor covers adding and troubleshooting off-the-shelf servers.
- Building your own MCP server is the cross-tool guide: prompts, REST and CLI wrappers, and packaging as npm or Docker.
- Reducing MCP token cost explains how tool count and result size eat the context window.
- Automation workflows in Cursor chains your new tools into repeatable jobs, and the Cursor SDK drives the agent from code.
- Privacy and security in Cursor sets what the agent may reach, and MCP server connection issues goes deeper on failures.