All articles

Build Your First MCP Server in TypeScript (2026)

SWATI BARWAL

SWATI BARWAL

·11 min read

mcp

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:

Bash
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/node

Add tsconfig.json:

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:

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

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

Bash
npm run dev

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

Bash
npm run build
npm start

Step 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):

JSON
{
  "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:

Bash
npx @modelcontextprotocol/inspector npx tsx /ABSOLUTE/PATH/notes-mcp/src/index.ts

This 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

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

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

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

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

TypeScript
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 possibleadd_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

SWATI BARWAL

Written by

SWATI BARWAL

1 follower

Related posts