Skip to content

Building Custom MCP Servers: Extend Cursor with Your Tools

Your team keeps pasting the same Postgres schema into chat, and Cursor still guesses column types wrong half the time. The data lives behind an internal API that the model has never seen, so every “write me a query” turns into three rounds of corrections. A custom MCP server fixes this permanently: you expose your database (or any internal tool) as first-class tools the agent can call, and Cursor stops guessing.

The good news is you do not have to hand-write the server. The fastest path is to let Cursor’s agent build it from a short spec plus the official SDK docs, then verify it with the MCP Inspector before you ever wire it into the editor. That is the workflow this guide walks through.

  • A repeatable Cursor agent workflow for scaffolding an MCP server from a spec.md plus @-mentioned SDK docs
  • A runnable read-only Postgres MCP server built on the current @modelcontextprotocol/sdk (1.29.x)
  • Copy-paste prompts to scaffold the server, harden a tool with validation, and convert stdio to remote Streamable HTTP
  • The exact ~/.cursor/mcp.json entry (with the required type: "stdio" field) to register it
  • A real “when this breaks” checklist for the failures that actually bite: stdout pollution, auth, and schema rejections

The Model Context Protocol is a client/server standard. Cursor is the client; your server advertises tools (callable functions like query), resources (browsable data like table listings), and communicates over stdio for local servers or Streamable HTTP for remote ones. You almost never write the protocol plumbing by hand — the SDK does that, and Cursor’s agent wires the SDK to your data.

  1. Create a project and install the two dependencies you need:

    Terminal window
    mkdir pg-mcp && cd pg-mcp
    npm init -y
    npm install @modelcontextprotocol/sdk zod postgres
  2. Write a spec.md next to your code. Keep it lightweight — a handful of bullets is enough for the agent to scaffold against:

    # 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

Step 2: Scaffold with the agent, not from memory

Section titled “Step 2: Scaffold with the agent, not from memory”

The single most important move: give the agent the current SDK README as context so it does not generate a pre-1.x API from its training data. Cursor’s own MCP cookbook does exactly this — it @-mentions the raw SDK and library READMEs in the prompt.

After a round or two of back-and-forth, you should land on something close to this. Note the shape: McpServer + registerTool, a Zod schema, a single CallToolResult returned, and StdioServerTransport to run it.

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

The key correctness detail: a tool callback returns one CallToolResult (a content array), not an async generator. If you need to report progress on a long task, the SDK exposes notifications through the handler’s extra parameter — not yield.

Resources let the agent browse your schema without burning a tool call. Use registerResource with a ResourceTemplate so the URI is dynamic:

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

Step 4: Verify with the MCP Inspector before touching Cursor

Section titled “Step 4: Verify with the MCP Inspector before touching Cursor”

Do not register an untested server into your editor — debug it in isolation first. The Inspector is a browser-based tool you launch with npx; there is no global install and no REPL.

Terminal window
npx @modelcontextprotocol/inspector npx tsx src/index.ts

This opens a local web UI where you can list the server’s tools and resources, fire query with a real statement, and watch the raw JSON-RPC exchange. If a tool throws or your schema rejects an input, you see it here — long before Cursor’s agent does.

Add the server to ~/.cursor/mcp.json (global) or .cursor/mcp.json (project-scoped). For stdio servers, Cursor now requires the type field:

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

Open Cursor Settings -> MCP / Tools & Integrations to confirm the server connected and its tools are listed (a green dot). Now ask the agent “how many orders shipped last week?” and it calls query against your real schema instead of hallucinating columns.

Running over stdio is perfect for one developer. When the whole team needs the same connection — shared credentials, central rate limiting, one place to update the schema — you deploy the server as an HTTP service. The modern transport is Streamable HTTP on a single /mcp endpoint. The old HTTP+SSE transport was deprecated in the MCP spec in favor of Streamable HTTP, and individual providers are sunsetting their /sse endpoints on their own schedules (Atlassian’s Rovo server, for example, drops /v1/sse on June 30, 2026), so do not build new servers on /sse.

The resulting Cursor config drops command/args and uses url plus a headers object — there is no transport key for remote servers:

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

For team distribution of the stdio version, publish it as an npm package with a bin entry and point command at the binary (npx @company/pg-mcp) instead of an absolute path.

You do not need a fake-client library — there is no @mcp/testing package. Test tool handlers two ways. The cheapest is to call the handler logic directly in Vitest. For an end-to-end check, the SDK ships an in-memory transport pair so you can connect a real Client to your McpServer without spawning a process:

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

MCP servers fail in a few predictable ways. These are the ones that cost the most time:

  • stdout pollution kills the connection. A stdio server speaks JSON-RPC over stdout. Any stray console.log corrupts the stream and Cursor reports the server as failed with no useful error. Send all logging to stderr (console.error) or a file, never console.log.
  • The server connects but no tools appear. Almost always a crash during startup — a missing DATABASE_URL, an unhandled promise, or a throw before server.connect(). Run it standalone in the Inspector (Step 4) to see the real stack trace; Cursor swallows it.
  • Tool calls time out on large results. Returning a 50k-row query as one text blob hangs the agent. Add LIMIT in the tool, or paginate. Long-running work should report progress via the handler’s extra parameter, not block.
  • Schema-validation rejections look like “the tool did nothing.” When Zod rejects an input, the agent gets a validation error, not a result. Make .refine() messages explicit (“Write operations are disabled…”) so the agent self-corrects instead of retrying blindly.
  • Auth failures on remote servers surface as a silent disconnect. A 401 from your /mcp endpoint shows up in Cursor as “server unavailable.” Check the server logs and confirm the Authorization header in mcp.json actually interpolated your env var (${env:MCP_TOKEN} requires the var to exist in Cursor’s launch environment).