Bookmark
БесплатноНе проверенA bookmark management MCP server that enables saving, searching, and managing bookmarks with tags and read/unread status, providing tools, resources, and prompt
Описание
A bookmark management MCP server that enables saving, searching, and managing bookmarks with tags and read/unread status, providing tools, resources, and prompts for integration.
README
A deliberately simple business case (a personal bookmark / reading-list manager) implemented the current standard way to build a Model Context Protocol server, so you can focus entirely on the technology:
- TypeScript + the official @modelcontextprotocol/sdk (high-level
McpServerAPI) - All three MCP primitives: tools, resources (static + templates), prompts
- Local dev & testing on stdio / Node HTTP — production on Cloudflare Workers (Durable Object storage, deployed with one command)
- Zod schemas as the single source of truth for validation, TypeScript types, and the JSON Schema shown to clients
- Structured tool output (
outputSchema+structuredContent) and tool annotations (readOnlyHint,destructiveHint, …) - Production patterns: pluggable storage adapters, stderr-only logging, atomic file writes, in-band error handling, origin validation, graceful shutdown, health endpoint
- End-to-end tests with a real MCP client over the SDK's in-memory transport
src/
├── index.ts # entrypoint: stdio transport (local use with Claude Code/Desktop)
├── http.ts # entrypoint: Node Streamable HTTP (local/self-hosted, session-managed)
├── worker.ts # entrypoint: Cloudflare Worker + Durable Object ← PRODUCTION
├── server.ts # MCP layer: registers tools, resources, prompts (transport-agnostic)
├── store.ts # domain layer: BookmarkStore (runtime-agnostic, no node:* imports)
├── storage/
│ ├── file.ts # StorageAdapter: JSON file with atomic writes (Node only)
│ └── memory.ts # StorageAdapter: in-memory (tests)
├── schemas.ts # Zod schemas: validation + types + JSON Schema, all from one place
├── config.ts # env-var configuration (Node entrypoints)
├── logger.ts # structured logger (stderr on Node, log stream on Workers)
├── server.test.ts # end-to-end protocol tests (client ↔ server, in-memory)
└── store.test.ts # domain unit tests
wrangler.jsonc # Cloudflare deployment config (DO binding + migration)
Why a bookmark manager?
The use case fits in one sentence — "save URLs, find them again, mark them read" — so every line of code is about how to build an MCP server, not about understanding a domain. Yet it is rich enough to exercise everything: create/read/update/delete actions, search filters, derived data (tag stats), duplicates and not-found errors, and persistence.
Quick start
npm install
npm test # 15 end-to-end + unit tests
npm run dev # run on stdio (for MCP clients)
npm run dev:http # Node server on http://127.0.0.1:3000/mcp
npm run dev:worker # the PRODUCTION worker, locally in workerd (http://localhost:8787/mcp)
npm run inspect # open the MCP Inspector UI against this server
npm run deploy # ship to Cloudflare Workers (needs `npx wrangler login` once)
Connect it to Claude Code
claude mcp add bookmarks -- npx tsx /absolute/path/to/playground_mcp/src/index.ts
Connect it to Claude Desktop
{
"mcpServers": {
"bookmarks": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/playground_mcp/src/index.ts"],
"env": { "BOOKMARKS_FILE": "/Users/you/bookmarks.json" }
}
}
}
Then ask things like "bookmark https://example.com/article with tag testing", "what's unread in my reading list?", or invoke the reading_digest prompt.
Configuration
| Env var | Default | Used by |
|---|---|---|
BOOKMARKS_FILE |
./data/bookmarks.json |
both transports |
LOG_LEVEL |
info |
both (debug/info/warn/error) |
PORT |
3000 |
HTTP only |
HOST |
127.0.0.1 |
HTTP only |
Architecture
Two design decisions make the "test locally, run on Cloudflare" split cheap:
- The MCP layer is transport-agnostic.
createServer()builds the same server whether it is served over stdio, Node HTTP, the Workers transport, or an in-memory pipe in tests. - The domain layer is runtime-agnostic.
store.tsuses only Web-standard APIs (nonode:*imports) and persists through a 2-methodStorageAdapterport. The file adapter is for laptops; the Durable Object adapter is production; the memory adapter is for tests.
flowchart LR
subgraph Clients
CD["Claude Desktop / Claude Code"]
IN["MCP Inspector"]
T["Vitest test client"]
end
subgraph Entrypoints
STDIO["index.ts<br/>stdio (local dev)"]
HTTP["http.ts<br/>Node Streamable HTTP"]
CF["worker.ts<br/>Cloudflare Worker + DO (production)"]
MEM["InMemoryTransport<br/>(tests)"]
end
subgraph Server["server.ts — createServer()"]
TOOLS["Tools<br/>add_bookmark · search_bookmarks<br/>mark_read · delete_bookmark"]
RES["Resources<br/>bookmarks://all · bookmarks://stats<br/>bookmarks://bookmark/{id}"]
PROMPTS["Prompts<br/>reading_digest"]
end
subgraph Domain["store.ts — BookmarkStore (runtime-agnostic)"]
STORE["StorageAdapter port"]
end
FILE[("storage/file.ts<br/>bookmarks.json, atomic writes")]
DO[("Durable Object storage<br/>strongly consistent")]
RAM[("storage/memory.ts")]
CD --> STDIO
IN --> STDIO
CD -.->|"remote: workers.dev/mcp"| CF
T --> MEM
STDIO --> Server
HTTP --> Server
CF --> Server
MEM --> Server
TOOLS --> Domain
RES --> Domain
PROMPTS --> Domain
STORE --> FILE
STORE --> DO
STORE --> RAM
The three MCP primitives — who controls what
| Primitive | Controlled by | This server | Typical UI |
|---|---|---|---|
| Tools | the model — the LLM decides when to call them | add_bookmark, search_bookmarks, mark_read, delete_bookmark |
tool-use with permission prompt |
| Resources | the application — the client attaches them as context | bookmarks://all, bookmarks://stats, bookmarks://bookmark/{id} (template) |
"attach context" picker |
| Prompts | the user — explicitly invoked | reading_digest |
slash command / menu |
Flows
1. Connection lifecycle (initialize handshake)
Every MCP session, on any transport, starts with the same three-step handshake in which client and server negotiate protocol version and capabilities:
sequenceDiagram
participant C as Client (Claude)
participant S as bookmark-mcp
C->>S: initialize (protocolVersion, capabilities, clientInfo)
S-->>C: result (serverInfo, capabilities: tools/resources/prompts, instructions)
C->>S: notifications/initialized
Note over C,S: Session is live
C->>S: tools/list
S-->>C: 4 tools with JSON Schemas + annotations
C->>S: resources/list · prompts/list
S-->>C: resource & prompt catalogs
Note over C,S: ... normal operation (see flow 2) ...
C->>S: close / SIGTERM
S->>S: flush write queue, close transport
2. Tool call flow (what happens on "bookmark this URL")
sequenceDiagram
actor U as User
participant L as LLM
participant C as MCP Client
participant S as server.ts
participant D as store.ts
U->>L: "Save https://ex.com/post with tag rust"
L->>C: tool_use: add_bookmark {url, tags:["rust"]}
C->>S: tools/call add_bookmark
S->>S: Zod validates input against schema
alt input invalid
S-->>C: result { isError: true, "Invalid URL ..." }
Note over L: LLM reads the error and self-corrects
else input valid
S->>D: store.add(...)
alt duplicate URL
D-->>S: DuplicateUrlError
S-->>C: result { isError: true, "already bookmarked (id ...)" }
else success
D->>D: atomic write: tmp file + rename
D-->>S: Bookmark
S-->>C: result { content: [text], structuredContent: {bookmark} }
end
end
C->>L: tool result
L->>U: "Saved! It's in your reading list under 'rust'."
Two error channels, used deliberately:
- In-band tool errors (
isError: true) for expected business failures — duplicates, not-found, invalid input. The LLM sees the message and can recover (e.g. search for the existing bookmark instead). - Protocol errors (JSON-RPC errors / thrown exceptions) only for unexpected bugs.
3. Streamable HTTP session lifecycle (Node self-hosted variant)
The stdio transport is one process per client — no session management needed. The Node remote server uses Streamable HTTP with explicit sessions:
sequenceDiagram
participant C as Remote client
participant H as http.ts (node:http)
participant T as StreamableHTTPServerTransport
participant S as McpServer (per session)
C->>H: POST /mcp (initialize, no session header)
H->>H: validate Origin header (DNS-rebinding defense)
H->>T: new transport + sessionIdGenerator()
H->>S: createServer(store).connect(transport)
T-->>C: 200 + Mcp-Session-Id: <uuid>
C->>H: POST /mcp (Mcp-Session-Id: <uuid>) — tools/call etc.
H->>T: route to session's transport
T-->>C: response (JSON or SSE stream)
C->>H: GET /mcp (Mcp-Session-Id) — optional
T-->>C: SSE stream for server→client notifications
C->>H: DELETE /mcp (Mcp-Session-Id)
T->>H: onsessionclosed → remove from session map
All sessions share one BookmarkStore, so the data is consistent across clients; each session gets its own McpServer instance, so protocol state never leaks between clients.
4. Persistence: why writes can't corrupt the data
Locally (FileStorage adapter):
flowchart TD
A["tool handler mutates Map"] --> B["persist() appends to write queue"]
B --> C{previous write done?}
C -- "no" --> W["wait (serialized writes)"] --> D
C -- "yes" --> D["write bookmarks.json.PID.tmp"]
D --> E["rename() over bookmarks.json — atomic on POSIX"]
E --> F["crash at any point ⇒ old file intact"]
In production the Durable Object gives the same guarantees for free: its storage API is transactional, and the DO is single-threaded so writes are serialized by the platform itself.
Production: Cloudflare Workers
worker.ts is the production entrypoint. The stateless Worker routes every request to one named Durable Object instance, which owns the data and runs the MCP server:
sequenceDiagram
participant C as MCP client (Claude)
participant W as Worker (edge, stateless)
participant D as Durable Object "default"
participant S as DO storage (SQLite-backed)
C->>W: POST https://bookmark-mcp.you.workers.dev/mcp
W->>D: idFromName("default") → stub.fetch(request)
Note over D: first request after cold start?
D->>S: read + Zod-validate persisted store
D->>D: fresh McpServer + WebStandard transport<br/>(stateless: no Mcp-Session-Id)
D->>S: transactional write on mutation
D-->>C: JSON-RPC response (plain JSON)
Why this shape:
- Stateless MCP (
sessionIdGenerator: undefined,enableJsonResponse: true): serverless requests may hit any isolate, so there are no sticky sessions to manage — each POST is self-contained. This is the recommended pattern for serverless MCP hosting. - One DO = the consistency boundary. DO storage is strongly consistent and the instance is single-threaded, so concurrent clients can't corrupt data — the platform replaces both the atomic file writes and the write queue we need locally.
McpAgentalternative: Cloudflare'sagentsframework is the batteries-included route (per-session DOs, hibernation, OAuth templates). It needs external shared storage (KV/D1) because each session gets its own DO; the single shared DO here keeps the showcase self-contained and dependency-light. Reach forMcpAgentwhen you need server→client notifications or the OAuth flow.- Multi-tenancy is one line away: derive the DO name from the authenticated user (
idFromName(userId)) and every user gets an isolated store.
Deploy
npx wrangler login # once
npm run deploy # builds + ships; prints https://bookmark-mcp.<you>.workers.dev
Connect Claude to the deployed server:
claude mcp add --transport http bookmarks https://bookmark-mcp.<you>.workers.dev/mcp
Local test of the exact production code path (runs in workerd, with a local DO):
npm run dev:worker # http://localhost:8787/mcp + /healthz
Before sharing the URL publicly, add auth — simplest is Cloudflare Access in front of the route; the full-fidelity option is the MCP OAuth 2.1 flow (workers-oauth-provider). The free plan (100k requests/day, SQLite-backed DOs included) comfortably covers personal use.
Production patterns demonstrated
| Concern | Where | Pattern |
|---|---|---|
| stdout discipline | logger.ts | On stdio, stdout is the protocol. One stray console.log kills the session — all logs are structured JSON on stderr. |
| Validation at the boundary | schemas.ts | Zod raw shapes with .describe() on every field → runtime validation + TS types + JSON Schema for the LLM, from one definition. |
| Structured output | server.ts | Tools declare outputSchema and return structuredContent next to human-readable content. |
| Tool annotations | server.ts | readOnlyHint on search, destructiveHint on delete (clients can require confirmation), idempotentHint on mark_read. |
| Recoverable errors | server.ts | Business failures are isError: true results the model can read; only bugs throw. |
| Pluggable storage | store.ts, storage/ | Runtime-agnostic domain layer + 2-method StorageAdapter port: file (local), Durable Object (production), memory (tests). |
| Durable writes | storage/file.ts, worker.ts | Locally: temp-file + rename() atomic writes behind a write queue. In production: transactional DO storage. Corrupt data fails loudly at startup. |
| Remote security | http.ts | Origin validation, 127.0.0.1 binding by default, per-session transports, /healthz for orchestrators. |
| Graceful shutdown | both entrypoints | SIGINT/SIGTERM close sessions and the transport before exiting. |
| Testing | server.test.ts | A real Client over InMemoryTransport.createLinkedPair() exercises the full JSON-RPC stack without spawning processes. |
| Config via env | config.ts | Matches how MCP clients pass configuration (env block in the client's server config). |
Production checklist (what's still missing before a public launch)
The Workers deployment already covers TLS, scaling, durable storage, and observability (wrangler tail / dashboard logs). What this showcase deliberately leaves out:
- Authentication — the MCP spec mandates OAuth 2.1 for remote servers. On Cloudflare:
workers-oauth-provider(full spec flow) or Cloudflare Access with a service token (pragmatic personal setup). On Node:@modelcontextprotocol/sdk/server/authhelpers. - Multi-tenancy — currently all clients share one bookmark collection; derive the DO name from the authenticated user to isolate stores.
- Rate limiting & request size caps — Cloudflare WAF rules or a rate-limit binding.
- Server→client notifications — the stateless Worker pattern has no SSE channel; if you need
listChangednotifications or progress streams, move to session-managed transports (Nodehttp.tsalready does this; on Workers useMcpAgent).
Extending the server
Adding a capability is a three-step pattern — schema, domain, registration:
- Define the input shape in schemas.ts with
.describe()on every field. - Add the operation to store.ts (plus a typed error class if it can fail in an expected way).
- Register it in server.ts with
registerTool/registerResource/registerPrompt, and add a case to server.test.ts.
Debugging
npm run inspect # MCP Inspector: interactive UI for tools/resources/prompts
LOG_LEVEL=debug npm run dev # verbose stderr logs (Node)
npm test # full protocol round-trip without any client
npm run dev:worker # production code path locally (workerd + local DO)
npx wrangler tail # live logs from the deployed Worker
Установка Bookmark
У этого сервера нет опубликованного пакета — он собирается из исходников. Открой репозиторий и следуй инструкции в README.
▸ github.com/privatenesk/playground_mcpFAQ
Bookmark MCP бесплатный?
Да, Bookmark MCP бесплатный — установка в пару кликов через Unyly без оплаты.
Нужен ли API-ключ для Bookmark?
Нет, Bookmark работает без API-ключей и переменных окружения.
Bookmark — hosted или self-hosted?
Self-hosted: сервер запускается локально на твоей машине командой из раздела установки.
Как установить Bookmark в Claude Desktop, Claude Code или Cursor?
Открой Bookmark на unyly.org, выбери вкладку своего клиента (Claude Desktop, Claude Code, Cursor) и нажми Install — конфиг сгенерируется автоматически, без правки JSON.
Похожие MCP
GitHub
PRs, issues, code search, CI status
автор: GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
автор: mcpdotdirectCompare Bookmark with
Не уверен что выбрать?
Найди свой стек за 60 секунд
Автор?
Embed-бейдж для README
Похожее
Все в категории development
