· cursor / mcp / typescript
How to Build MCP Tools That Work Inside Cursor
Step-by-step guide to building a custom MCP server in TypeScript and wiring it into Cursor Pro. Covers tool registration, restart quirks, and the five gotchas that waste an afternoon.
By Ethan
1,306 words · 7 min read
You need Cursor Pro ($20/month) to use MCP. It is not available on the free Hobby plan. If you’re still on Hobby, this tutorial is a preview of what you unlock when you upgrade.
Once you’re on Pro, MCP turns Cursor Chat into something closer to a local agent: it can call functions you define, read your filesystem, run your test suite, hit your internal APIs — anything you expose as a tool. This guide builds two tools from scratch, registers them in Cursor, and covers the five configuration mistakes that cost most people an afternoon.
Who this is for
Developers who use Cursor daily and want to extend it with custom automations. You need Node 18+, a working TypeScript setup, and Cursor Pro. If you’re not yet on Cursor Pro, the configuration steps here won’t appear in your UI.
What MCP is (the short version)
Model Context Protocol is an open standard for wiring AI assistants to external capabilities. Think of it as USB-C for AI tooling: a single protocol that any host (Cursor, Claude Desktop, Zed) and any server (your code, a database, a REST API) can speak. There are three primitives: Tools (functions the model can call), Resources (data sources the model can read), and Prompts (reusable prompt templates). This tutorial covers Tools only — they’re the primitive you’ll reach for 90% of the time.
Full spec: modelcontextprotocol.io/docs.
Prerequisites
- Node 18 or later (
node -vto check) - TypeScript 5.x (
npx tsc --version) - Cursor Pro
Step 1: Create the MCP server package
mkdir cursor-mcp-tools && cd cursor-mcp-tools
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir dist
Add "type": "module" to package.json — the MCP SDK uses ES modules.
{
"name": "cursor-mcp-tools",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Failure mode: omitting "type": "module" causes ERR_REQUIRE_ESM at runtime. Cursor’s error output is terse; this is the most common first-hour debugging trap.
Step 2: Write the first tool — search_files
Create src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
const server = new McpServer({
name: "cursor-mcp-tools",
version: "1.0.0",
});
server.tool(
"search_files",
"Search files in a directory for a text pattern",
{
directory: z.string().describe("Absolute path to search"),
pattern: z.string().describe("Text pattern to match"),
},
async ({ directory, pattern }) => {
try {
const entries = await readdir(directory, { withFileTypes: true });
const matches: string[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const filePath = join(directory, entry.name);
const content = await readFile(filePath, "utf-8").catch(() => "");
if (content.includes(pattern)) {
matches.push(entry.name);
}
}
return {
content: [
{
type: "text",
text:
matches.length > 0
? `Found in: ${matches.join(", ")}`
: "No matches found.",
},
],
};
} catch (err) {
return {
isError: true,
content: [{ type: "text", text: `Error: ${(err as Error).message}` }],
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Failure mode: any console.log or console.error call in your tool handler corrupts the stdio transport — MCP uses stdin/stdout as the message channel. Use process.stderr.write(...) if you need debug output. This mistake produces cryptic parse errors in Cursor and nothing else.
Build it:
npm run build
You should see dist/index.js with no TypeScript errors.
Step 3: Register the server in Cursor
Create (or edit) .cursor/mcp.json in your project root — or in ~/.cursor/mcp.json to make it available across all projects:
{
"mcpServers": {
"cursor-mcp-tools": {
"command": "node",
"args": ["/absolute/path/to/cursor-mcp-tools/dist/index.js"]
}
}
}
Replace /absolute/path/to/cursor-mcp-tools with the real path on your machine. Relative paths do not work here — Cursor spawns the server process from its own working directory, not yours.
Fully restart Cursor — not just reload the window (Cmd+Shift+P → Reload Window). MCP servers are spawned at application startup. A window reload does not respawn them. This trips up almost everyone the first time.
After restart, open Cursor Chat and look for Available Tools in the model settings panel. search_files should appear there.
Failure mode: if the tool doesn’t appear, the most likely cause is a missing mcpServers key (the top-level JSON key is required, even if it feels redundant). Check .cursor/mcp.json exactly matches the structure above.
Step 4: Call the tool from chat
In Cursor Chat, type:
Search /Users/yourname/projects/my-app for the string "TODO"
Cursor will show an approval prompt before calling the tool — this is intentional. Approve it. You’ll see the tool response inline in chat: the filenames that matched, or “No matches found.”
The model decides when to invoke a tool based on the description string you passed to server.tool(...). Write descriptions that match how you’d phrase requests in natural language.
Step 5: Add a second tool — run_tests
Extend src/index.ts before the connect call:
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
server.tool(
"run_tests",
"Run the test suite for a project and return the output",
{
projectPath: z.string().describe("Absolute path to the project root"),
testCommand: z
.string()
.default("npm test")
.describe("Command to run tests (default: npm test)"),
},
async ({ projectPath, testCommand }) => {
try {
const { stdout, stderr } = await execAsync(testCommand, {
cwd: projectPath,
timeout: 60_000,
});
return {
content: [{ type: "text", text: stdout || stderr }],
};
} catch (err: any) {
return {
isError: true,
content: [
{
type: "text",
text: `Tests failed:\n${err.stdout ?? ""}\n${err.stderr ?? ""}`,
},
],
};
}
}
);
The isError: true flag is important. It tells Cursor that the tool call failed — which causes the model to read the error output and respond accordingly, rather than treating a non-zero exit as a successful empty result. Always set isError: true in your catch blocks.
Rebuild and restart Cursor after adding the second tool.
Gotchas worth knowing
console.log corrupts the transport. MCP uses stdin/stdout as a message bus. Any console.log call — even in a dependency — will inject text into the stream and break parsing. Use process.stderr.write(...) for debug output, not console.*.
Missing mcpServers key. Cursor parses .cursor/mcp.json strictly. The mcpServers top-level key must be present. An otherwise valid JSON file without it produces no error and no tools.
npx -y for server commands. If you’re running an MCP server via npx (e.g., a published package), add the -y flag: "command": "npx", "args": ["-y", "some-mcp-server"]. Without -y, npx pauses to confirm the install — which hangs the stdio channel permanently.
Absolute paths everywhere. The args array in .cursor/mcp.json must use absolute paths. ~/ expansion doesn’t happen. ../ relative paths resolve from Cursor’s process directory, which is not your project root.
ESM module config. The MCP TypeScript SDK is ESM-only. Your tsconfig.json must target NodeNext module resolution and your package.json must include "type": "module". Missing either produces runtime import errors that look like SDK bugs.
What you built
| Item | Detail |
|---|---|
| MCP server | TypeScript, two tools (search_files, run_tests) |
| Registration | .cursor/mcp.json, project-scoped |
| Time to first working tool | ~20 minutes |
| What you gained | Cursor Chat can search your filesystem and run your tests on demand |
From here: add more tools for your own workflows — query a local database, call an internal API, read environment config. The pattern is always the same: server.tool(name, description, schema, handler). The description is the only thing the model sees when deciding whether to call your tool, so write it like a function docstring.
Full MCP Inspector for debugging your server locally: modelcontextprotocol.io/docs/tools/inspector.