Part 2 plugged in someone else’s server. This page is the other half of day one: you write a small MCP server in TypeScript, prove its tools with the Inspector, then point Cursor at your local build over stdio.

Before you start

What you will build

A bookmarks lab: a JSON file on disk, three tools (list_bookmarks, add_bookmark, search_bookmarks), no API keys. Companion code lives at geekmonks-mcp-bookmarks on tag part-3.

git clone https://github.com/SRVivek1/geekmonks-mcp-bookmarks.git
cd geekmonks-mcp-bookmarks
git checkout part-3
npm install
npm run build

You can also follow the scaffold below in a fresh folder — same shape as the tag.

Scaffold

Pin the SDK that matches protocol 2026-07-28 (TypeScript SDK v2):

{
  "name": "geekmonks-mcp-bookmarks",
  "version": "0.3.0",
  "type": "module",
  "bin": {
    "geekmonks-mcp-bookmarks": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "engines": {
    "node": ">=20"
  },
  "dependencies": {
    "@modelcontextprotocol/server": "2.0.0",
    "zod": "4.4.3"
  },
  "devDependencies": {
    "@types/node": "^22.13.10",
    "typescript": "^5.8.2"
  }
}

tsconfig.json (emit to dist/):

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

A tiny store module (src/bookmarks.ts) reads and writes ./data/bookmarks.json (or BOOKMARKS_PATH). Clone tag part-3 if you want the file as-is; the listing below is the whole module so a fresh folder can compile.

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';

export type Bookmark = { id: string; title: string; url: string };

const storePath = process.env.BOOKMARKS_PATH ?? path.join(process.cwd(), 'data', 'bookmarks.json');

async function readStore(): Promise<Bookmark[]> {
  try {
    const raw = await readFile(storePath, 'utf8');
    return JSON.parse(raw) as Bookmark[];
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
      return [];
    }
    throw err;
  }
}

async function writeStore(rows: Bookmark[]): Promise<void> {
  await mkdir(path.dirname(storePath), { recursive: true });
  await writeFile(storePath, JSON.stringify(rows, null, 2), 'utf8');
}

export async function loadBookmarks(): Promise<Bookmark[]> {
  return readStore();
}

export async function addBookmark(title: string, url: string): Promise<Bookmark> {
  const rows = await readStore();
  const entry: Bookmark = { id: crypto.randomUUID(), title, url };
  rows.push(entry);
  await writeStore(rows);
  return entry;
}

export async function searchBookmarks(query: string): Promise<Bookmark[]> {
  const q = query.toLowerCase();
  const rows = await readStore();
  return rows.filter(
    (row) => row.title.toLowerCase().includes(q) || row.url.toLowerCase().includes(q),
  );
}

The interesting part is the MCP surface.

Register tools

Create src/index.ts. You build an McpServer, register tools with Zod inputSchema, then hand a factory to serveStdio:

import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
import { addBookmark, loadBookmarks, searchBookmarks } from './bookmarks.js';

function createServer(): McpServer {
  const server = new McpServer({
    name: 'geekmonks-mcp-bookmarks',
    version: '0.3.0',
  });

  server.registerTool(
    'list_bookmarks',
    {
      title: 'List bookmarks',
      description: 'List all saved bookmarks from the local JSON store.',
    },
    async () => {
      const bookmarks = await loadBookmarks();
      return {
        content: [{ type: 'text' as const, text: JSON.stringify(bookmarks, null, 2) }],
      };
    },
  );

  server.registerTool(
    'add_bookmark',
    {
      title: 'Add bookmark',
      description: 'Add a bookmark with a title and URL to the local JSON store.',
      inputSchema: z.object({
        title: z.string().min(1).describe('Short title for the bookmark'),
        url: z.string().url().describe('Full URL to save'),
      }),
    },
    async ({ title, url }) => {
      const entry = await addBookmark(title, url);
      return {
        content: [{ type: 'text' as const, text: JSON.stringify(entry, null, 2) }],
      };
    },
  );

  server.registerTool(
    'search_bookmarks',
    {
      title: 'Search bookmarks',
      description: 'Search bookmarks by substring match on title or URL.',
      inputSchema: z.object({
        query: z.string().describe('Text to find in title or URL'),
      }),
    },
    async ({ query }) => {
      const matches = await searchBookmarks(query);
      return {
        content: [{ type: 'text' as const, text: JSON.stringify(matches, null, 2) }],
      };
    },
  );

  return server;
}

void serveStdio(createServer);
console.error('geekmonks-mcp-bookmarks (tools) running on stdio');

What matters on first read:

PieceJob
McpServerHigh-level server: name, version, and registrations
registerToolName + description + optional Zod inputSchema + async handler
Zod schemaOne place for validation; the SDK derives JSON Schema for the host
Handler returncontent blocks (here: text with JSON)
serveStdio(createServer)Owns stdin/stdout; builds the server for the connection

Official walkthrough: Build your first server and MCP build-server docs.

stdio and stderr logging

stdio means the host starts your process and speaks JSON-RPC on stdin/stdout.

Never console.log (or anything else) to stdout in a stdio server — you will corrupt the protocol stream. Use console.error or a logger that writes to stderr, as in the banner line above.

Build and bin

npm install
npm run build

You should get dist/index.js. Cursor (and Inspector) will run something like:

node /ABS/PATH/TO/geekmonks-mcp-bookmarks/dist/index.js

The bin field in package.json is for later packaging (npx); for this lab, calling node dist/index.js is enough.

Inspector lab first

Prove the server without the LLM. From the repo root after build:

npx @modelcontextprotocol/inspector node dist/index.js

In the Inspector UI:

  1. Connect.
  2. Open Tools — you should see list_bookmarks, add_bookmark, search_bookmarks.
  3. Call add_bookmark with a title and a valid URL.
  4. Call list_bookmarks — you should see your new entry as JSON.
  5. Call search_bookmarks with part of the title — matching rows only.

If Inspector cannot connect, fix the build and the node dist/index.js command before touching Cursor.

Wire Cursor

Add a project .cursor/mcp.json (field meanings: Part 2):

{
  "mcpServers": {
    "bookmarks": {
      "type": "stdio",
      "command": "node",
      "args": ["/ABS/PATH/TO/geekmonks-mcp-bookmarks/dist/index.js"]
    }
  }
}

Use a real absolute path. Reload MCP / restart as needed. Under Customize → MCP, the bookmarks server should show Connected with the three tools.

Open a new Agent chat and ask something natural:

Add a bookmark titled GeekMonks with URL https://blog.geekmonks.com and then list my bookmarks.

Approve the tool calls. You should see the add result and a list that includes the new bookmark.

Troubleshooting

SymptomWhat to check
Inspector fails to startnpm run build succeeded; node dist/index.js path is correct.
Cursor red / disconnectedSame command in a terminal; Node on PATH; absolute path in args.
Tools missing after code changeRebuild (npm run build), reload MCP, new Agent chat.
Garbled / broken protocolSearch your code for console.log / prints to stdout — move logs to stderr.
Need host logsOutput panel → MCP Logs (Cursor MCP docs).

Wrap-up

You registered tools on an McpServer, served them over stdio, verified with Inspector, and attached the build in Cursor. Series hub: MCP Without the Hype. Companion checkpoint: tag part-3 on geekmonks-mcp-bookmarks.

Next optional step Add a resource and a prompt on the same server. MCP Beyond Tools