Build a Searchable Work Journal MCP Server in 190 Lines of TypeScript
A small MCP server over Markdown files: four intent-based tools, prompt-style descriptions, a stdio demo client that doubles as a test, plus real gotchas.
This article is published in English.
Sources: I Built an MCP Server That Keeps My Work Journal — Here’s Everything I Learned
A Model Context Protocol server of about 190 lines of TypeScript can turn a folder of Markdown files into a searchable work journal that any AI assistant reads and writes. It also makes a good first MCP project, and it shows how to demo a server without an editor.
Why a diary
Daily logging habits rarely survive a week. Meanwhile, the AI assistant that debugged the race condition, co-wrote half the tests and watched every refactor already knows what happened all day. Discipline was never the real obstacle. The log lives somewhere other than the work.
Flip the arrangement, then: don't have a person feed a journal, give the assistant tools to keep one. MCP, the standard that lets LLM clients discover and call external tools, fits naturally. A dev-diary server that wraps a plain directory of Markdown files covers every core idea (tools, schemas, stdio transport, stateless storage) in under 200 lines, which makes it a well-sized way to learn.
The diary itself isn't the point. Journal apps exist already. The subject is small, dull and universally understood, which suits a learning project. The underlying question is whether anyone can build an MCP server for anything. Yes, and the protocol turns out to be the easy part. Deciding what to wrap is harder. A gym log, a reading list, an internal wiki or a smart home can sit behind an MCP server if code can read or write it.
The four tools
| Tool | Purpose |
|---|---|
log_work |
Appends a timestamped, tagged entry to today's file |
search_diary |
Full-text search across every entry |
daily_summary |
Everything recorded on a chosen date |
stats |
Totals, streaks and top tags |
Storage is deliberately plain: one Markdown file per day, with entries like these.
## 14:32 #bugfix #websocket
Fixed the race condition in the WebSocket broadcast queue
## 16:10 #testing
Wrote E2E test covering two-client sync
There's no database. The files can be grepped, committed and edited by hand, and that choice pays off later.
Building the server
The official TypeScript SDK (v2, which implements the 2026-07-28 spec) is the base. Installation needs the server package and Zod:
npm install @modelcontextprotocol/server zod
Imports bring in the server class, the stdio transport and Zod's v4 entry point:
import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
The server object takes a name and version:
const server = new McpServer({ name: 'dev-diary', version: '1.0.0' });
Each tool is registered with a name, a config and a handler. This is log_work:
server.registerTool(
'log_work',
{
description: 'Append a timestamped entry to the developer diary...',
inputSchema: z.object({
text: z.string().min(1),
tags: z.array(z.string()).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
}),
},
async ({ text, tags = [], date }) => {
// ...append to diary/YYYY-MM-DD.md...
return { content: [{ type: 'text', text: 'Logged.' }] };
},
);
Last, connect over stdio:
await server.connect(new StdioServerTransport());
That's a complete server. Four concepts carry it:
registerTool(name, config, handler)builds the tool catalog.inputSchemais a Zod schema. The SDK converts it to JSON Schema for the client and validates arguments before the handler runs.- The return value, shaped like
{ content: [{ type: 'text', text: '...' }] }, is what the LLM reads as the result. - The transport,
StdioServerTransport(), makes the client launch the process and talk JSON-RPC over stdin and stdout. Local use needs no ports and no auth.
Descriptions are prompts
This is the biggest mental shift. A tool description isn't API documentation for people. It's an instruction the model reads to decide when and how to call the tool. Compare a documentation-style description:
// ❌ documentation-style
description: 'Appends an entry to the diary.'
with a prompt-style one:
// ✅ prompt-style
description: 'Append a timestamped entry to the developer diary for today.
Use this whenever the user says they finished/did/fixed something and
wants it recorded.'
The second tells the model when to reach for the tool. Its "use this whenever" clause converts an offhand remark about finally fixing a flaky test into a tool call, with no explicit command. Parameter descriptions work the same way: a hint such as Optional labels, e.g. ["bugfix","api"] teaches the expected shape by example.
Tools map to questions, not operations
A first draft with CRUD tools (add_entry, update_entry, delete_entry) has the wrong shape. Nobody asks a journal to update entry 47. They ask what happened about the WebSocket bug last week. So the final set follows intent: search_diary finds things, daily_summary recaps a day, stats provides the motivational view and log_work is the single writer. Four tools overlap nowhere, and each answers something a developer really asks. The rule of thumb: one tool per user question, not one per data operation.
Demoing without an editor
A harder problem is how to show an MCP server working. Connecting Claude Desktop and screenshotting a chat proves nothing reproducible, depends on a closed app and can't run in CI or on a reader's machine.
The spec's quiet strength helps here: MCP is just client and server over stdio. Nothing prevents a second small program from connecting the way an editor would and driving the server end to end. Its imports:
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
It spawns the server as a child process:
const transport = new StdioClientTransport({
command: 'node',
args: ['dist/server.js'], // spawns the server as a child process
});
const client = new Client({ name: 'demo-client', version: '1.0.0' });
await client.connect(transport);
Then it does what a desktop client does behind the scenes, listing tools and calling them:
// Exactly what Claude Desktop does under the hood:
const { tools } = await client.listTools();
await client.callTool({ name: 'log_work', arguments: {
text: 'Fixed the race condition in the broadcast queue',
tags: ['bugfix', 'websocket'],
}});
About fifty lines later, npm run demo prints a complete protocol session as evidence. The tool listing:
=== 1. listTools ===
• log_work — Append a timestamped entry to the developer diary...
• search_diary — Full-text search across every entry...
• daily_summary — Everything logged on a given date...
• stats — Totals, active days, streaks, top tags...
Three writes:
=== 2. log_work x3 ===
Logged to 2026-08-22.md at 19:05 (tags: bugfix, websocket)
...
And the stats view:
=== 5. stats ===
📊 1 entries across 2 day(s)
🔥 Streak: 2 consecutive day(s)
🏷️ Top tags: #bugfix (1), #websocket (1)
Because it's a script, it doubles as an integration test. After each server change, the demo runs, and a broken handshake or drifted schema fails loudly. Demo, documentation and test suite become one artifact. For a visual route, the official Inspector, started with npx @modelcontextprotocol/inspector node dist/server.js, opens a web UI for poking at tools by hand. The two together cover development and demonstration.
Gotchas
- Top-level await can break builds. The SDK README uses
await server.connect(...)at module scope. That's fine for ESM packages ("type": "module") and a hard error when TypeScript compiles to CommonJS. Wrapping startup inmain()costs nothing and works everywhere. exactOptionalPropertyTypesconflicts with optional schemas. Under strict TypeScript, passingtags: undefinedto az.string().optional()field errors. Relax the flag or normalize arguments at the handler boundary.- Descriptions occupy context. Every tool description goes to the model in each conversation where the server is connected. Marketing copy wastes tokens and dilutes useful instructions, so short, imperative, intent-focused text wins.
- Statelessness helps. The server keeps nothing in memory and rereads the Markdown on each call. Hand edits between calls never produce stale data, and two clients on one folder share the truth for free. Hold off on caches for file-based tools until profiling justifies them.
- Timezones bite even in a toy.
new Date().toISOString().slice(11,16)returns UTC. Someone at UTC-3 logging at 6 PM sees 21:05. One line fixes it once it's noticed.
Wiring it up
In Claude Desktop, Cursor or any MCP-capable client, the configuration (in claude_desktop_config.json style) looks like this:
{
"mcpServers": {
"dev-diary": {
"command": "node",
"args": ["/absolute/path/to/dev-diary-mcp/dist/server.js"],
"env": { "DIARY_DIR": "/home/you/journal" }
}
}
}
Point DIARY_DIR at a folder inside a dotfiles repository and the journal versions itself as a side effect. After a week, a request such as "summarize my week from the diary, grouped by tag" produces a status report nobody had to write.
Why plain Markdown
The most opinionated choice deserves a defense. SQLite offers better queries but opaque storage, and JSON parses easily but diffs badly. Markdown won for four reasons:
- The human is a first-class client. The journal reads fine in an editor, with grep or in git logs, with no tool needed.
- Git supplies backup and history.
git log diary/is an audit trail that no database gives by default. - LLMs handle Markdown very well. What the model writes matches what's stored, with no serialization mismatch.
- Failures are gentle. A corrupted line is visible and fixable by hand, which a corrupted SQLite page isn't.
The lesson reaches beyond journals: when people and LLMs both consume your data, store it in a format both already speak.
Running it
Clone, build and run the demo:
git clone https://github.com/rogeriolaa/dev-diary-mcp
cd dev-diary-mcp && npm install && npm run build && npm run demo
Then add the server to the editor's MCP config and let the assistant start logging. Server, demo client and README together come to roughly 400 lines including configuration.
Small servers like this are an effective way to learn MCP: every concept gets touched, the result gets used daily and the surface area stays small enough to understand completely.