# Build a custom MCP server with Cursor's agent

URL: https://developertoolkit.ai/en/cursor-ide/automate/custom-mcp-servers/
Description: Have Cursor's agent build a read-only Postgres MCP server on the v2 TypeScript SDK, prove it with Inspector and tests, then register it in mcp.json.

A 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](/en/cursor-ide/quick-start/mcp-setup/).

## 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.json` entry 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?

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](/en/ecosystem/mcp/database-mcp/) | 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](/en/cursor-ide/automate/custom-rules-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](/en/cto-scorecard-guide/internal-mcp-servers/) for the build-or-buy rule |

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

1. Create the project and install the v2 server SDK, Zod, the `postgres` driver, and the test tools. The v2 SDK replaces the single `@modelcontextprotocol/sdk` package with separate server and client packages.

   ```bash
   mkdir pg-mcp && cd pg-mcp
   npm init -y && npm pkg set type=module
   npm install @modelcontextprotocol/server zod postgres
   npm install -D @modelcontextprotocol/client tsx vitest typescript @types/node
   ```

2. Write `spec.md`. The acceptance criteria become the test names in the verification step, so write them as observable behaviour.

   ```markdown
   # 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 ONLY
     transaction, 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
   ```

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

**Copy-paste prompt: build the server from the spec**

```text
Read spec.md and follow it exactly. Dependencies are installed; do not add any.

Use only symbols exported by the installed packages: check
node_modules/@modelcontextprotocol/server/dist/index.d.mts and
node_modules/@modelcontextprotocol/server/dist/stdio.d.mts before you use a name.
The v1 package @modelcontextprotocol/sdk is NOT installed; do not import from it.

Write src/server.ts exporting buildServer(sql: Sql) that registers the `query`
tool (inputSchema as z.object, annotations.readOnlyHint true) and the
pg://table/{name} resource template. Write src/index.ts that calls
serveStdio(() => buildServer(sql)). Log only with console.error.
When done, run `npx tsc --noEmit` and fix every error before you reply.
```

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.

```typescript
// src/server.ts
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;
}
```

```typescript
// src/index.ts
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 (both release channels on 2026-10-17); `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](/en/ecosystem/mcp/spec-2026-07-28/) covers the migration for an existing v1 server.

### 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`:

```sql
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

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:

```bash
# DATABASE_URL points at the mcp_reader role, loaded from your .env
set -a; . ./.env; set +a
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts -e "DATABASE_URL=$DATABASE_URL" --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts -e "DATABASE_URL=$DATABASE_URL" --protocol-era legacy --method tools/list
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts -e "DATABASE_URL=$DATABASE_URL" --protocol-era modern --method resources/read --uri pg://table/orders
```

Keep 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:

```typescript
// test/server.test.ts
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);
  });
});
```

```bash
# TEST_DATABASE_URL points at a disposable database seeded with an orders table
npx vitest run
```

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

**Copy-paste prompt: turn the spec into tests first**

```text
Read the "Acceptance criteria" section of spec.md. For each criterion except the
stdout one (the Inspector run covers it), write one Vitest case in test/server.test.ts that connects a Client from
@modelcontextprotocol/client to buildServer(sql) through
InMemoryTransport.createLinkedPair(). Use TEST_DATABASE_URL for the connection
and close it in afterAll. Run `npx vitest run`. If a test fails, fix
src/server.ts, not the test, and tell me which criterion was failing and why.
Do not weaken an assertion to make it pass.
```

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

```json
{
  "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](/en/cursor-ide/quick-start/mcp-setup/)) 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](/en/claude-code/quick-start/mcp-setup/) and [MCP setup in Codex](/en/codex/quick-start/mcp-setup/).

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

**Copy-paste prompt: add an HTTP entry point**

```text
Add src/http.ts that serves the existing buildServer(sql) over Streamable HTTP
without changing src/server.ts. Use createMcpHandler from
@modelcontextprotocol/server and toNodeHandler from @modelcontextprotocol/node
(install it) on a plain node:http server. Require the header
"Authorization: Bearer <MCP_TOKEN>" on every request, compared with
crypto.timingSafeEqual, and answer 401 otherwise. Serve only /mcp and paths under /mcp/.
Read DATABASE_URL, MCP_TOKEN and PORT from the environment and exit on stderr
if either of the first two is missing. Then run the Inspector CLI against
http://localhost:3000/mcp in both --protocol-era modern and legacy with the
header, and show me the tools/list output.
```

The result should look like this, and it is small enough to review in full:

```typescript
// src/http.ts
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:

```bash
npx @modelcontextprotocol/inspector --cli https://mcp.example.com/mcp --transport http --header "Authorization: Bearer $MCP_TOKEN" --protocol-era modern --method tools/list
```

In Cursor, the remote entry uses `url` and `headers` instead of `command` and `args`:

```json
{
  "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](/en/ecosystem/mcp/registry-and-gateways/). [Securing and hardening MCP servers](/en/ecosystem/mcp/mcp-security/) covers the threat model.

## 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](/en/ecosystem/mcp/spec-2026-07-28/) |
| 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 |

Never point a write-capable role at production through an MCP server, even with the regex and the read-only transaction in place. If the agent must change data, give it a tool per business operation (for example `cancel_order(id)`) with its own validation and an audit log, not raw SQL.

## Where to go next with custom MCP servers

- Prerequisite: [MCP setup in Cursor](/en/cursor-ide/quick-start/mcp-setup/) covers adding and troubleshooting off-the-shelf servers.
- [Building your own MCP server](/en/ecosystem/mcp/custom-mcp-development/) is the cross-tool guide: prompts, REST and CLI wrappers, and packaging as npm or Docker.
- [Reducing MCP token cost](/en/ecosystem/mcp/reducing-mcp-token-cost/) explains how tool count and result size eat the context window.
- [Automation workflows in Cursor](/en/cursor-ide/automate/automation-workflows/) chains your new tools into repeatable jobs, and the [Cursor SDK](/en/cursor-ide/automate/cursor-sdk/) drives the agent from code.
- [Privacy and security in Cursor](/en/cursor-ide/team/privacy-security/) sets what the agent may reach, and [MCP server connection issues](/en/ecosystem/mcp/mcp-connection-issues/) goes deeper on failures.
- [MCP TypeScript SDK v2: server guide](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools) — The SDK maintainers' reference for tools, resources, prompts, and serving over stdio and HTTP.
