Building tools is the fun half. The other half is making sure those tools cannot quietly trash data, leak secrets, or run with more power than the job needs. This page is the safety story for MCP servers — complete on its own — applied to the bookmarks lab.
Before you start
If terms like Tool, stdio, or registerTool are unfamiliar, read MCP Without the Hype and Build Your First MCP Server first — this post will not re-teach those basics.
Bootstrap from the companion tag (tools + resource + prompt already present):
git clone https://github.com/SRVivek1/geekmonks-mcp-bookmarks.git
cd geekmonks-mcp-bookmarks
git checkout part-4
npm install
npm run build
You will end on tag part-6: geekmonks-mcp-bookmarks.
Trust boundaries
Draw the line once:
| Boundary | What crosses it |
|---|---|
| Model → tools | The model proposes a call; your host should require approval for side effects |
| Server process → disk/API | Your handler code is the real power — schemas alone are not a security boundary |
| Config → secrets | API keys and tokens live in env / secret stores, never in tool results or git |
MCP does not make unsafe tools safe. A well-typed delete_everything tool is still a delete button with a schema.
Host approval UX
In Cursor (and similar hosts), mutating tools should surface as approval prompts. Treat that as a product control, not a guarantee:
- Prefer read-only tools for day-one demos (
list_*,search_*,get_*). - Give mutating tools clear names and descriptions (
add_bookmark, notsave). - Do not assume every host will always ask — validate and authorize in the server anyway.
Secrets and config
| Do | Don’t |
|---|---|
Pass secrets via env (BOOKMARKS_PATH, API tokens) | Hard-code keys in source or mcp.json committed to git |
| Use host env interpolation when the host supports it | Echo secrets into tool content text |
| Log to stderr only on stdio | console.log secrets (or anything) on stdout |
For local labs, a path env var is enough. For remote HTTP servers, require a bearer/API key on every request (Part 8).
Validate every tool argument
Zod (or JSON Schema) at registration time is your first filter — keep it strict:
inputSchema: z.object({
title: z.string().min(1).max(200).describe('Short title for the bookmark'),
url: z
.string()
.url()
.refine((u) => u.startsWith('https://'), {
message: 'Only https URLs are allowed',
})
.describe('HTTPS URL to save'),
}),
Extra server-side checks still belong in the handler (normalize URL, reject javascript: if your stack allows weird schemes, cap list sizes).
Read-only vs mutating
Split the surface on purpose:
| Kind | Examples | Default posture |
|---|---|---|
| Read-only | list_bookmarks, search_bookmarks, resource reads | Safe to expose early |
| Mutating | add_bookmark (this lab). A delete_bookmark tool is an optional extension, not in Part 3. | Require approval + validation + least data written |
A useful pattern for public or shared servers: ship read-only first, add writes behind an explicit flag or separate server entry.
Least privilege on the host side
Your bookmarks lab only needs one JSON file. Do not:
- Point tools at the whole home directory “for convenience”
- Run the server as a privileged OS user
- Grant network egress the tool never needs (when you add HTTP clients later)
Same idea as Part 2’s throwaway folder: bound the blast radius.
Remote auth overview (floor, not IdP course)
When the transport is Streamable HTTP:
- API key / bearer token — day-one pattern: client sends
Authorization: Bearer …; server rejects missing/wrong tokens before any tool runs. - OAuth / user-delegated access — when each end user must authorize access to their data; out of scope to build an IdP here — link out when you need it.
- TLS — public URLs without HTTPS are practice targets, not products.
Hardening checklist (apply to the lab)
Work through this on your part-4 checkout, then commit as part-6:
- Inventory tools — label each read-only or mutating.
- Tighten schemas — max lengths,
https-only URLs, required fields. - Handler guards — reject empty titles; cap how many bookmarks you return in one call.
- Output hygiene — never return env vars, file paths outside the store, or stack traces to the model.
- Logging — stderr only; no secrets in logs.
- Docs — README states what the server can change on disk.
- Gate before publish — if you would not run it against real data, do not
npm publishyet (Part 7).
Example guard inside add_bookmark:
async ({ title, url }) => {
const trimmed = title.trim();
if (!trimmed) {
return {
content: [{ type: 'text' as const, text: 'Title must not be empty.' }],
isError: true,
};
}
const entry = await addBookmark(trimmed, url);
return {
content: [{ type: 'text' as const, text: JSON.stringify(entry, null, 2) }],
};
},
You should see: Inspector still lists the same tools; invalid URLs fail validation; valid add_bookmark still writes one row; list_bookmarks never dumps process env.
Wrap-up
Hardening is trust boundaries, approval UX, secrets in env, strict schemas, read-only defaults, and least privilege — then a checklist you can re-run before every publish. Series hub: MCP Without the Hype. Build baseline: Part 3. Code: tag part-6 on geekmonks-mcp-bookmarks.