Build Your First MCP Server in TypeScript (2026)
SWATI BARWAL
·11 min read
If you already know what MCP is, the next step is writing your own server. This tutorial shows you how to build an MCP server in TypeScript from scratch — a small notes server with two tools and one resource — then connect it to Cursor so Agent mode can save and list notes through your code.
You will use the official @modelcontextprotocol/sdk, stdio transport (the standard for local servers), and patterns that still apply as the July 2026 MCP spec evolves toward stateless HTTP.
Author: Aneh Thakur · Last updated: 2026-07-09
What you'll learn
Scaffold a TypeScript MCP server with the official SDK
Register tools (add_note, list_notes) the model can call
Expose a resource (notes://index) the model can read
Run the server over stdio for local hosts (Cursor, Claude Desktop)
Wire the server into Cursor MCP settings safely
Debug common failures (schema errors, path issues, permissions)
Prerequisites
Node.js 18+ (tested on Node 23)
Cursor with MCP support (Settings → MCP)
Basic TypeScript (async/await, import)
Optional: read MCP in Cursor setup guide first for host-side security context
What we're building
A local notes MCP server named notes-mcp:
Capability | Name | Purpose |
|---|---|---|
Tool | add_note | Save a title + body in memory |
Tool | list_notes | Return all note titles |
Resource | notes://index | Read-only dump of all notes |
Data lives in an in-memory Map — perfect for learning. Production servers swap this for a database or API (see the Zerodha MCP walkthrough for a real API integration).
Step 1 — Create the project
Create a folder and install dependencies:
mkdir notes-mcp && cd notes-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/nodeAdd tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}Create src/index.ts — this is your entire first MCP server:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import * as z from "zod";
const server = new McpServer({
name: "notes-mcp",
version: "1.0.0",
});
const notes = new Map<string, string>();
server.registerTool(
"add_note",
{
description: "Add a short text note by title",
inputSchema: {
title: z.string().min(1).describe("Note title"),
body: z.string().min(1).describe("Note body"),
},
},
async ({ title, body }) => {
notes.set(title, body);
return {
content: [
{
type: "text",
text: `Saved note "${title}" (${body.length} chars)`,
},
],
};
}
);
server.registerTool(
"list_notes",
{
description: "List all note titles",
inputSchema: {},
},
async () => {
const titles = [...notes.keys()];
return {
content: [
{
type: "text",
text: titles.length ? titles.join("\n") : "No notes yet.",
},
],
};
}
);
server.registerResource(
"notes-index",
"notes://index",
{
description: "Index of saved notes",
mimeType: "text/plain",
},
async () => ({
contents: [
{
uri: "notes://index",
mimeType: "text/plain",
text:
[...notes.entries()]
.map(([t, b]) => `# ${t}\n${b}`)
.join("\n\n") || "No notes yet.",
},
],
})
);
const transport = new StdioServerTransport();
await server.connect(transport);Why stdio?
Local MCP hosts (Cursor, Claude Desktop) spawn your server as a child process and talk over stdin/stdout. That is why you must not use console.log for debugging in production — it corrupts the JSON-RPC stream. Use stderr or the SDK logging helpers instead.
Step 2 — Run the server locally
Add a script to package.json:
{
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}Smoke test (the process should sit idle — that means it is listening):
npm run devPress Ctrl+C to stop. If you see a stack trace about missing modules, double-check "type": "module" in package.json and that import paths end with .js (required for Node ESM + TypeScript).
Optional compile step:
npm run build
npm startStep 3 — Understand tools vs resources
Feature | Who initiates | Use when |
|---|---|---|
Tool | Model calls your function | Actions: create, update, search, run commands |
Resource | Model reads URI content | Static or slow-changing data: docs, config, indexes |
Prompt | Host lists templates | Reusable prompt patterns (not covered here) |
In our server:
add_note / list_notes are tools — the model passes arguments and gets structured text back.
notes://index is a resource — the model fetches the full index when it needs context without calling a tool.
Keep tool inputs small and validated. The SDK uses Zod schemas in inputSchema so hosts can show proper forms and reject bad calls early.
Step 4 — Connect to Cursor
1. Open Cursor → Settings → MCP → Add new MCP server. 2. Use stdio with npx + tsx (simplest during development):
{
"mcpServers": {
"notes-mcp": {
"command": "npx",
"args": [
"-y",
"tsx",
"/ABSOLUTE/PATH/TO/notes-mcp/src/index.ts"
]
}
}
}Replace /ABSOLUTE/PATH/TO/notes-mcp with your real path (e.g. /Users/you/projects/notes-mcp).
3. Save and restart Cursor if the server does not appear. 4. Confirm the server shows a green status in MCP settings.
Production tip: After npm run build, point command to node and args to ["/absolute/path/notes-mcp/dist/index.js"] so you are not depending on tsx at runtime.
Security scoping
Run only servers you wrote or audited.
Do not point MCP at directories with secrets (.env, ~/.ssh, production credentials).
Prefer a dedicated project folder for experiments.
See MCP security in Cursor before exposing filesystem or shell tools.
Step 5 — Test in Cursor Agent mode
Open a project, enable Agent mode, and try:
1. *"Use the notes MCP server to add a note titled shopping with body milk, eggs, coffee."* 2. *"List all notes from the notes MCP server."* 3. *"Read the notes://index resource and summarize it."*
Expected behavior:
Cursor calls add_note with title and body.
list_notes returns shopping.
The resource returns markdown-style text with the note body.
If tools never run, open MCP logs in Cursor settings and check for spawn errors (wrong path, missing tsx, or TypeScript compile errors).
Step 6 — Inspect with MCP Inspector (optional)
The MCP project ships a browser-based inspector useful when Cursor is not in the loop:
npx @modelcontextprotocol/inspector npx tsx /ABSOLUTE/PATH/notes-mcp/src/index.tsThis opens a local UI where you can:
1. List registered tools and resources 2. Call add_note with sample JSON 3. Read notes://index directly
Use Inspector when developing tools before wiring Cursor — faster iteration, clearer error messages than Agent chat alone.
Step 7 — Connect to Claude Desktop
The same stdio server works in Claude Desktop. Edit your config file:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"notes-mcp": {
"command": "npx",
"args": ["-y", "tsx", "/ABSOLUTE/PATH/notes-mcp/src/index.ts"]
}
}
}Restart Claude Desktop. Ask: *"Add a note titled standup with body: ship MCP tutorial today."* Claude should call your tool the same way Cursor does.
Step 8 — Add error handling
Production servers return tool errors instead of throwing uncaught exceptions (which can crash stdio processes):
server.registerTool(
"add_note",
{
description: "Add a short text note by title",
inputSchema: {
title: z.string().min(1).max(80).describe("Note title"),
body: z.string().min(1).max(4000).describe("Note body"),
},
},
async ({ title, body }) => {
if (title.includes("/")) {
return {
isError: true,
content: [{ type: "text", text: "Title cannot contain '/'." }],
};
}
notes.set(title, body);
return {
content: [{ type: "text", text: `Saved note "${title}"` }],
};
}
);Also add a top-level guard in a main() wrapper if you prefer explicit exit codes:
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
console.error("notes-mcp failed:", err);
process.exit(1);
});Step 9 — Package for teammates
Checklist before sharing:
1. Pin dependency versions in package.json (no floating latest in npx for production). 2. Add a README.md with tool list and Cursor config snippet. 3. Document required env vars (none for this demo; real APIs need API_KEY via env, never hardcoded). 4. Add engines: { "node": ">=18" }. 5. Run npm run build and commit dist/ only if your team avoids build steps — otherwise document npm install && npm run build.
For a published npm CLI, add "bin" and use npx your-package in Cursor config — same pattern as @modelcontextprotocol/server-filesystem.
Step 10 — What to build next
Upgrade | Why |
|---|---|
Persist notes to SQLite | Survive server restarts |
Add search_notes tool | Filter by keyword |
HTTP transport | Remote team access (plan for 2026 OAuth requirements) |
Narrow tool scopes | Read-only vs write tools for safer agents |
Structured output schema | Validate JSON responses with outputSchema |
The concepts (tools, resources, transports) stay stable even as the spec adds stateless HTTP and server/discover. Follow the official migration notes when you ship remote servers.
Example: `search_notes` tool
A natural third tool filters without returning the full resource:
server.registerTool(
"search_notes",
{
description: "Find note titles containing a keyword (case-insensitive)",
inputSchema: {
query: z.string().min(1).describe("Substring to search in titles"),
},
},
async ({ query }) => {
const q = query.toLowerCase();
const matches = [...notes.keys()].filter((t) => t.toLowerCase().includes(q));
return {
content: [
{
type: "text",
text: matches.length ? matches.join("\n") : `No titles match "${query}".`,
},
],
};
}
);Rebuild, restart the MCP host, and ask Cursor to *"search notes for shop"* — you should see shopping if you ran the earlier test.
Example: persist to disk
Swap the Map for a JSON file when you want notes to survive restarts:
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const STORE = new URL("../notes.json", import.meta.url);
const notes = new Map<string, string>(
existsSync(STORE)
? Object.entries(JSON.parse(readFileSync(STORE, "utf8")))
: []
);
function persist() {
writeFileSync(STORE, JSON.stringify(Object.fromEntries(notes), null, 2));
}Call persist() at the end of add_note. Scope the file inside your project directory — never write to system paths the model could abuse.
MCP server design rules (2026)
1. One server, one domain — notes, GitHub, or Postgres; not everything in one binary. 2. Validate inputs with Zod — never trust free-form model strings in SQL or shell. 3. Idempotent tools when possible — add_note could upsert; document behavior in description. 4. Short tool descriptions — hosts show them to the model; be explicit about side effects. 5. No stdout logging — use stderr or SDK logging. 6. Fail closed — return isError: true rather than partial writes.
Tool naming and descriptions
Hosts pass your tool metadata to the model. Follow these conventions:
Rule | Good | Bad |
|---|---|---|
Name | add_note, list_notes | Add Note, tool1 |
Description | "Add a short text note by title" | "Note tool" |
Args | describe() on every Zod field | Undocumented optional fields |
The model chooses tools based on names and descriptions. If list_notes fails because the model calls get_notes, add an alias tool or improve the description — do not rely on the model guessing undocumented names.
Debugging checklist
When something fails silently:
1. Run the server command manually in a terminal — confirm no immediate crash. 2. Use MCP Inspector to call tools with fixed JSON inputs. 3. Check Cursor MCP logs for ENOENT, EACCES, or module resolution errors. 4. Temporarily log to stderr only: console.error("add_note", title). 5. Pin @modelcontextprotocol/sdk version in package.json — breaking SDK releases happen.
Troubleshooting
Cursor shows MCP server red / disconnected
Cause: Wrong absolute path, missing tsx, or server crashes on boot.
Fix: Run the same command + args in your terminal. Fix TypeScript errors. Use absolute paths only.
Tools do not appear in Agent chat
Cause: Server connected but tool registration failed, or Agent mode disabled.
Fix: Toggle MCP server off/on. Confirm registerTool names use snake_case without spaces. Restart Cursor.
`console.log` broke the server
Cause: stdout is reserved for MCP protocol messages.
Fix: Remove debug logs or write to console.error (stderr).
Zod / schema errors at runtime
Cause: SDK version mismatch or invalid inputSchema shape.
Fix: Align zod with SDK peer requirements (npm ls zod @modelcontextprotocol/sdk). Keep inputSchema as an object of Zod fields, not a bare z.object() unless documented.
Notes disappear after restart
Cause: In-memory Map is expected behavior.
Fix: Add persistence (file, SQLite) in a follow-up iteration.
FAQ
How do I build an MCP server in TypeScript?
What is the difference between MCP tools and resources?
Can I use this MCP server with Claude Desktop?
Do I need to publish to npm?
Is stdio MCP secure?
What changed in the MCP spec in 2026?
Related posts
What Is MCP? A Developer's Guide (2026) — architecture, hosts, and protocol basics
MCP Servers in Cursor: Setup & Security Guide (2026) — connect and harden MCP in your IDE
Build an MCP Server for Zerodha (AI Trading with Claude) — real-world API tools beyond hello-world


