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:

BoundaryWhat crosses it
Model → toolsThe model proposes a call; your host should require approval for side effects
Server process → disk/APIYour handler code is the real power — schemas alone are not a security boundary
Config → secretsAPI 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, not save).
  • Do not assume every host will always ask — validate and authorize in the server anyway.

Secrets and config

DoDon’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 itEcho secrets into tool content text
Log to stderr only on stdioconsole.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:

KindExamplesDefault posture
Read-onlylist_bookmarks, search_bookmarks, resource readsSafe to expose early
Mutatingadd_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:

  1. API key / bearer token — day-one pattern: client sends Authorization: Bearer …; server rejects missing/wrong tokens before any tool runs.
  2. 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.
  3. 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:

  1. Inventory tools — label each read-only or mutating.
  2. Tighten schemas — max lengths, https-only URLs, required fields.
  3. Handler guards — reject empty titles; cap how many bookmarks you return in one call.
  4. Output hygiene — never return env vars, file paths outside the store, or stack traces to the model.
  5. Logging — stderr only; no secrets in logs.
  6. Docs — README states what the server can change on disk.
  7. Gate before publish — if you would not run it against real data, do not npm publish yet (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.

Optional next Package and publish for npx / the MCP Registry. Ship Your MCP Server