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
- Glossary: tool, stdio, schema in MCP Without the Hype.
- Optional: Plug In an MCP Server in Cursor for
mcp.jsonfield refreshers. - Node.js 20+ and npm on your PATH.
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:
| Piece | Job |
|---|---|
McpServer | High-level server: name, version, and registrations |
registerTool | Name + description + optional Zod inputSchema + async handler |
| Zod schema | One place for validation; the SDK derives JSON Schema for the host |
| Handler return | content 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:
- Connect.
- Open Tools — you should see
list_bookmarks,add_bookmark,search_bookmarks. - Call
add_bookmarkwith a title and a valid URL. - Call
list_bookmarks— you should see your new entry as JSON. - Call
search_bookmarkswith 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
| Symptom | What to check |
|---|---|
| Inspector fails to start | npm run build succeeded; node dist/index.js path is correct. |
| Cursor red / disconnected | Same command in a terminal; Node on PATH; absolute path in args. |
| Tools missing after code change | Rebuild (npm run build), reload MCP, new Agent chat. |
| Garbled / broken protocol | Search your code for console.log / prints to stdout — move logs to stderr. |
| Need host logs | Output 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.