I asked Claude what unread bookmarks I had. It didn't guess, it called a tool on a live MCP server and read back real data:
"List all unread bookmarks with their respective URL in a table."
Then I narrowed it:
"Just show me the MCP-related ones."
In the second screenshot, of the 5 "mcp"-tagged entries in the DB, only 3 are unread, which is why they also showed up in the first result set. The other 2 are marked read.
Two different questions, two different tools picked correctly. No code written in the moment, just a small Python server exposing three read-only functions over the internet. That's the whole idea behind MCP, and this post walks through how it's built, deployed, and wired into an MCP client like Claude.
-> You can see the full conversation here.
What is MCP?
The Model Context Protocol (MCP), introduced by Anthropic in 2024, is a client/server standard for how an LLM (Large Language Model) calls out to external tools and data sources (e.g. files, databases) during a conversation, instead of only working from what it already knows.
You describe a set of functions (tools) in plain language, the model reads those descriptions (prompts), decides which one (if any) answers the user's question, and calls it with the right arguments.
It provides a unified protocol for tool-calling, eliminating the need to write custom integration code for every client. The same pattern that powers this bookmark demo is what's behind larger, production connectors like GitHub, Notion, and Slack. They're built on the same tool-calling contract, just with more tools and real write access. This project is a small version of that idea: three tools, one table, no writes.
Project Overview
The demo is a bookmark manager: a SQLite database of saved links, and a simple Python MCP server that exposes three functions to query it. The full code is available on GitHub.
list_all- Returns every bookmark, read or unread.get_unread- Returns only unread bookmarks.search_by_tag- Returns bookmarks matching a given tag.
It's deliberately read-only. There's no add_bookmark or mark_read tool (or other write operations), on purpose. A few reasons:
No input sanitization needed (no writes).
No need to think about concurrent writes.
No write-auth needed.
The database is seeded with 35 sample bookmarks spread across six tags: mcp, reference, python, webdev, hosting, and ai. Enough to give the demo something real to query. To inspect the raw data, sqliteviewer.app allows direct browsing of .sqlite files directly in the browser.
Architecture
The demo uses a lightweight architecture designed to demonstrate tool registration and core MCP concepts without unnecessary complexity.
The Schema
CREATE TABLE bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT NOT NULL,
tag TEXT,
read INTEGER NOT NULL DEFAULT 0,
saved_date TEXT NOT NULL DEFAULT (date('now'))
);One table, six columns. read is a boolean flag (Boolean values are stored as integers in SQLite), tag is a plain string rather than a normalized foreign key.
This is a demo project, not a production bookmark app, so I kept the schema as flat as possible while keeping it practical.
Three Tools
Here are key snippets from the Python server script:
from mcp.server.mcpserver import MCPServer
...
mcp = MCPServer("bookmark-manager")
...
@mcp.tool()
def list_all() -> str:
"""List all bookmarks, read or unread."""
...
@mcp.tool()
def get_unread() -> str:
"""List all unread bookmarks."""
...
@mcp.tool()
def search_by_tag(tag: str) -> str:
"""List all bookmarks matching a given tag."""
...The important thing here isn't the SQL, it's the docstrings. In an MCP server, your docstring is your public API interface. The LLM never sees the database schema or the code. It only receives the tool's exported signature: its name, type annotations, parameter descriptions, and docstring. That metadata is the only context the model uses to determine whether a tool answers the query and which arguments to pass.
You can see this in action right in the intro to this post:
"List all unread bookmarks with their respective URL in a table." mapped cleanly to
get_unreaddocstring because of its explicit description."Just show me the MCP-related ones." mapped to
search_by_tag(tag="MCP")because the parameter context is unambiguous.
Neither call was ambiguous, because neither docstring was ambiguous. If both tools had simple descriptions (e.g. "fetches bookmarks"), the model would be forced to guess, execute the wrong function, or waste a turn asking for clarification.
Example: Early on, search_by_tag just had the docstring "Search bookmarks." When asked to "Find bookmarks about Python.", the model skipped search_by_tag entirely and tried calling list_all, planning to filter the output manually.
Expanding the docstring to explicitly state "Search bookmarks matching a given tag or topic" immediately fixed the routing.
Two Transports: stdio vs. streamable-http
This server is deployed and reachable over the internet, so the transport choice actually matters here. In practice, it supports both: stdio for local development, and streamable-http for the deployed version, switched by the MCP_TRANSPORT environment variable.
To tell your client (Claude, Qwen, or any other MCP-compatible app) how to talk to your server, MCP uses a configuration file: .mcp.json. Inside this file, you declare your server entries under mcpServers.
stdio, Locally
When you run an MCP server locally, the simplest transport is stdio: the client spawns your server as a subprocess and talks to it over stdin/stdout. No network, no ports, no auth to think about. The client owns the process.
{
"mcpServers": {
"bookmark-manager-local": {
"command": "/absolute/path/to/mcp_server/.venv/Scripts/python.exe",
"args": ["/absolute/path/to/mcp_server/server.py"]
}
}
}This is fine as long as the server only ever needs to run on the same machine as the client. It breaks the moment you want to deploy the server somewhere and have multiple clients (or a client on a different machine) reach it.
streamable-http, Remotely
To make the server reachable from anywhere (as shown in the screenshots above), it needs to run as a long-lived web service instead of a subprocess. That's what streamable-http transport is for: the server binds to a host and port and speaks MCP over HTTP instead of stdio.
Two things had to change to make that work:
The server needs to bind to 0.0.0.0 instead of localhost, so it accepts outside traffic, and listen on $PORT rather than a hardcoded number, since Render (the hosting service) assigns that dynamically.
Transport selection needed to be configurable, not hardcoded. The same codebase runs locally over stdio during development and remotely over streamable-http in production, switched by an environment variable.
The server script uses three environment variables:
DB_PATH- where the SQLite file lives (defaults todata/bookmarks.sqlite)MCP_TRANSPORT-stdio(default) orstreamable-httpPORT- which port the server binds to (defaults to8000. Render sets this automatically at runtime)
{
"mcpServers": {
"bookmark-manager-remote": {
"type": "http",
"url": "https://mcp-demo-pzap.onrender.com/mcp"
}
}
}Side by side, the difference is simply where the process lives: stdio config launches a process, and streamable-http config points at a URL. Since the server supports both via the MCP_TRANSPORT switch, environment swapping requires zero changes to your server code.
Deployment
The server is deployed as a web service on Render's free tier, with the seeded SQLite database committed to the repo as read-only data. No separate database provisioning step, no migrations to run on deploy. The whole dataset ships with the code, ensuring zero data drift (bookmarks are never written to) between what is in the repo and what is live.
The live server is reachable at:
https://mcp-demo-pzap.onrender.com/mcp
Keep in mind that free-tier instances sleep after inactivity. The quickest way to spin it back up is simply opening the service URL in your browser before using it. You'll see a protocol error, that's expected since MCP expects POST requests rather than a browser GET, but the request wakes the server up so it's ready when your MCP client calls it.
The only env var I had to set in the Render dashboard was MCP_TRANSPORT, set to streamable-http. DB_PATH and PORT both work fine on their defaults: Render sets $PORT automatically, and the seeded SQLite file already lives where DB_PATH expects it.
Wiring It Up
Since the server is deployed and reachable over the internet, it can be added directly to Claude.ai as a custom connector. No local setup, no .mcp.json file needed.
From Settings -> Connectors, adding a custom connector, and pasting in the server's URL is enough for Claude to pick up the tools it exposes.
Once added, Claude picks up the three tools automatically. From there, it behaves exactly like the transcript in the beginning of this post, a plain-language question gets mapped to the right tool call:
I also tried a similar prompt with Qwen as a client (no manual connector setup needed). It picked up the tools automatically and fetched real data from the live endpoint. The prompt was the following:
Check out the following MCP server address:
https://mcp-demo-pzap.onrender.com/mcp
List all unread bookmarks with their URLs in a table.
Qwen initialized an MCP session and successfully executed get_unread to retrieve all matching records (partial view shown for brevity).
-> You can inspect the Qwen conversation to see the raw tool output.
The Bigger Picture
This project is deliberately small, so it's worth being explicit about where it sits relative to the connectors you'd actually use day to day:
GitHub, Notion, Slack, and other connectors expose dozens of tools, support both reads and writes, and handle real authentication (OAuth, scoped tokens) on behalf of a specific user's account. This bookmark server exposes three tools, is entirely read-only, and has no authentication at all. Anyone who points a client at the URL gets the same data.
A minimal server like this is a good way to better understand the protocol (the tool-definition contract, the transport question, the deploy-and-connect loop) without the complexity of an auth flow or a large tool surface obscuring what's actually happening. It gives a clean foundation for building custom MCP tools into existing apps.
Wrap-Up
The full project is on GitHub.
If you're looking to extend it, a good next step is adding a write tool. For example, implementing an add_bookmark or remove_bookmark function guarded by a basic API key lets you explore two-way protocol interactions and custom auth while keeping the codebase small and readable.
If you build on this, discover alternative ways to structure tool docstrings, or have any other suggestions, feel free to open an issue or start a discussion on the GitHub repo.