{"slug": "build-your-first-mcp-server-in-python-give-claude-your-own-notes", "title": "Build your first MCP server in Python: give Claude your own notes", "summary": "A developer built a local MCP server in Python that lets Claude AI search and retrieve personal blog posts. The server, about 60 lines of code, uses the Model Context Protocol to provide two tools: search_notes and get_note. It runs locally via stdio transport with no deployment or Docker needed.", "body_md": "There are 48 posts on this site. I wrote every one of them so I would never have to Google the same thing twice, and it works: when I need the Netplan syntax or the exact `ufw`\n\nincantation, I come here instead of wading through Stack Overflow.\n\nThe annoying part is that the model I am talking to has not read any of it. It knows the general shape of Netplan, not the version I settled on after the third time it bit me. I can paste a post into the chat window, and I have, plenty of times. But pasting is not a system. It is a thing you do again every session, forever, and it only works when you already know which post you needed.\n\nWhat I actually want is a door. Let the model knock when it wants something, and let it read the notes itself.\n\nThat door is the **Model Context Protocol**, and the useful part is not the protocol. It is that you write the integration once. Without MCP, giving a model access to your notes means a bespoke integration per tool per client: one for Claude Code, another for the desktop app, another for whatever you use next year. With MCP you write one server that knows how to search your notes, and every client that speaks the protocol can call it. The server does not know or care who is asking.\n\nThis post builds that server. It is about sixty lines of Python.\n\nTL;DR`uv add \"mcp[cli]\"`\n\n, decorate two functions with`@mcp.tool()`\n\n, end the file with`mcp.run(transport=\"stdio\")`\n\n, and register it with`claude mcp add notes -- uv run --directory /abs/path server.py`\n\n. Use an absolute path or it will not connect, and never`print()`\n\nto stdout, because stdout is the transport.\n\nYou need [uv](https://peculiarengineer.com/blog/install-uv-macos-cheat-sheet/) and Claude Code. That is the whole list. There is no server to deploy, no Docker, no port to open. A local MCP server is just a program that Claude Code launches as a subprocess and talks to over stdin and stdout.\n\nThat last point is worth sitting with, because it is the thing people expect to be complicated and it is not. MCP defines two transports. **Streamable HTTP** is for remote servers that many clients connect to over the network, and it comes with the whole authentication story. **stdio** is for local servers, and it is a pipe. Your process, the client's process, JSON going back and forth. Nothing is listening on a port and nothing is exposed.\n\nEverything below is stdio. It is the right place to start, and for a server that reads files on your own laptop it may be the right place to stay.\n\nTwo tools:\n\n`search_notes(query)`\n\nfinds posts containing a phrase and returns their slugs and titles.`get_note(slug)`\n\nreturns one whole post.That split matters more than it looks. Search returns a small list so the model can pick, then it fetches only what it wants. If `search_notes`\n\nreturned full post bodies, a three word query would dump 40,000 words into the context window and the model would drown before it started.\n\nI am pointing this at my blog because that is what I have. Point `NOTES`\n\nat any folder of markdown and it works identically. That is the whole idea.\n\n```\nmkdir mcp-notes && cd mcp-notes\nuv init .\nuv add \"mcp[cli]\"\n```\n\n`mcp[cli]`\n\nis the official Python SDK. The `cli`\n\nextra brings along the development tooling, which you will want later.\n\nHere is the whole thing, `server.py`\n\n:\n\n```\n\"\"\"An MCP server that lets Claude read my blog posts.\n\nTwo tools: search_notes finds posts containing a phrase, get_note returns one\nwhole post. Point NOTES at any folder of markdown and it works the same.\n\"\"\"\n\nimport sys\nfrom pathlib import Path\n\nfrom mcp.server.fastmcp import FastMCP\n\nNOTES = Path.home() / \"projects/peculiarengineer/src/content/blog\"\n\nmcp = FastMCP(\"notes\")\n\ndef _posts() -> list[Path]:\n    return sorted(NOTES.glob(\"*.md\")) + sorted(NOTES.glob(\"*.mdx\"))\n\ndef _title(text: str, fallback: str) -> str:\n    # Frontmatter title, e.g.  title: 'Set up SSH keys for Ubuntu'\n    for line in text.splitlines()[:15]:\n        if line.startswith(\"title:\"):\n            return line.removeprefix(\"title:\").strip().strip(\"'\\\"\")\n    return fallback\n\n@mcp.tool()\ndef search_notes(query: str) -> str:\n    \"\"\"Search my blog posts for a word or phrase.\n\n    Matches the literal phrase anywhere in a post, including its frontmatter.\n    Returns up to 5 hits with slug and title. There is no ranking: hits come\n    back in filename order, so a post that merely mentions the phrase can crowd\n    out the post that is about it. Search more than once with different wording.\n    Use get_note with a slug to read the full post.\n    \"\"\"\n    hits = []\n    for path in _posts():\n        text = path.read_text(encoding=\"utf-8\")\n        if query.lower() in text.lower():\n            hits.append(f\"{path.stem}: {_title(text, path.stem)}\")\n        if len(hits) == 5:\n            break\n\n    if not hits:\n        return f\"No posts mention {query!r}.\"\n    return \"\\n\".join(hits)\n\n@mcp.tool()\ndef get_note(slug: str) -> str:\n    \"\"\"Return the full text of one blog post, by slug.\n\n    The slug is the filename without its extension, as returned by search_notes.\n    \"\"\"\n    for path in _posts():\n        if path.stem == slug:\n            return path.read_text(encoding=\"utf-8\")\n    return f\"No post with slug {slug!r}. Use search_notes to find one.\"\n\nif __name__ == \"__main__\":\n    # stdout is the transport. A stray print() lands in the middle of the\n    # JSON-RPC stream, and block buffering means it may not bite until the\n    # buffer fills hours later. Diagnostics go to stderr, always.\n    print(f\"serving {len(_posts())} posts from {NOTES}\", file=sys.stderr)\n    mcp.run(transport=\"stdio\")\n```\n\nThat is a complete MCP server. Three parts are doing the work.\n\n`FastMCP(\"notes\")`\n\nis the server. The name is what shows up in clients.\n\n`@mcp.tool()`\n\nis where the magic is, and it is worth understanding what it saves you. The protocol requires each tool to advertise a JSON Schema describing its inputs. FastMCP builds that schema from your **type hints**, and it takes the tool's description from your **docstring**. You write a normal Python function and the protocol paperwork is generated for you. This is why the post is short.\n\n`mcp.run(transport=\"stdio\")`\n\nstarts the loop that reads JSON off stdin and writes JSON to stdout.\n\nNotice the search is a case insensitive substring match. That is not a placeholder I am going to upgrade at the end. It is the point: the protocol is twenty minutes of work, and search quality is a different problem that would eat this entire post. More on where that falls over below.\n\nOne command:\n\n```\nclaude mcp add notes -- uv run --directory /Users/you/projects/mcp-notes server.py\n```\n\nThe `--`\n\nmatters. Everything before it belongs to `claude mcp add`\n\n, and everything after it is the literal command Claude Code runs to start your server. Without the separator, `claude`\n\ntries to parse your command's flags as its own.\n\nThe `--directory`\n\nmatters more, and this is the first thing that got me. Claude Code runs that command from **whatever directory you launched claude in**, not from where your server lives. I registered it with a bare\n\n`uv run server.py`\n\n, started Claude in my blog repo, and got this:\n\n```\nnotes: uv run server.py - ✘ Failed to connect\n```\n\nOf course it failed. There is no `server.py`\n\nin my blog repo. `uv run --directory /abs/path server.py`\n\npins it, and now it does not matter where you start Claude from.\n\nCheck it:\n\n```\nclaude mcp list\nnotes: uv run --directory /Users/you/projects/mcp-notes server.py - ✔ Connected\n```\n\n`✔ Connected`\n\nmeans Claude Code launched the process, completed the handshake, and got a tool list back. For more detail, `claude mcp get notes`\n\nshows the scope, the exact command, and any error.\n\nBy default your server is added with **local** scope, which means it is private to you and tied to the directory you added it from. This confused me for a solid minute: I added the server while sitting in my blog repo, went over to the server's own directory, ran `claude mcp list`\n\n, and it was not there. Not broken, not an error, just not listed. Local scope is per project, and I was in a different project.\n\nYour options:\n\n`--scope local`\n\n(the default) for this project only, stored in `~/.claude.json`\n\n.`--scope user`\n\nfor every project you work in. This is what you want for a notes server.`--scope project`\n\nwrites a `.mcp.json`\n\nin the repo so anyone who clones it gets the server too.Start Claude Code and ask it something that is in your notes and not in its training data:\n\n```\n> What did I decide about Secure Boot when installing NVIDIA drivers?\n```\n\nThe first time a tool runs, Claude Code asks your permission, once per tool. Approve it and you will not be asked again. The names are namespaced by server, so mine show up as `mcp__notes__search_notes`\n\nand `mcp__notes__get_note`\n\n. `/mcp`\n\ninside a session lists the server and its tools.\n\nThen it works. It calls `search_notes(\"Secure Boot\")`\n\n, gets a slug back, calls `get_note`\n\non it, and answers out of my own post: if Secure Boot is on, enroll the MOK key when the driver install prompts you, or turn Secure Boot off in the BIOS before you start.\n\nThat is the correct answer, and it is correct because it came from [my own post](https://peculiarengineer.com/blog/install-ollama-ubuntu-26-04-nvidia-gpu/) rather than from a general impression of how NVIDIA drivers work.\n\nBut the part I did not expect was what came next. Unprompted, it searched again, found my [Hardening Ubuntu 26.04 Desktop](https://peculiarengineer.com/blog/hardening-ubuntu-26-04-desktop/) post, and pointed out that the two contradict each other: on the desktop I recommend TPM backed full disk encryption, which relies on Secure Boot being on. So \"turn Secure Boot off\" is advice scoped to a headless GPU box, not a rule, and I had never written that down anywhere because I had never had both posts in my head at the same time.\n\nThat is the moment the door earns its keep. I did not ask it to reconcile two posts written a month apart. It just had access to both.\n\nI promised this, and it is more interesting than I expected.\n\nAsk for something specific and it is great. `search_notes(\"fail2ban\")`\n\nreturns exactly the right three posts. Then try a general word:\n\n```\nsearch_notes(\"firewall\")\ncreate-sudo-user-ubuntu-26-04: Create a Sudo User on Ubuntu 26.04\nenable-ssh-on-ubuntu-desktop: Enable SSH on an Ubuntu desktop\nextend-azure-windows-disk-run-command: Extending C: on locked-down Azure Windows VMs\nhardening-ubuntu-26-04-desktop: Hardening Ubuntu 26.04 Desktop (Resolute Raccoon)\nhardening-ubuntu-26-04-server: Hardening Ubuntu 26.04 Server (Resolute Raccoon)\n```\n\nEvery one of those is a real match. And the actual firewall post, [UFW Firewall Basics](https://peculiarengineer.com/blog/ufw-firewall-basics-ubuntu/), is not in the list.\n\nThe substring match did not fail. It found the UFW post fine. The problem is that there is no ranking at all: `_posts()`\n\nreturns files in alphabetical order and I stop at five, so five posts that mention \"firewall\" once in passing crowded out the post that is entirely about firewalls, purely because `u`\n\nsorts after `c`\n\n, `e`\n\n, and `h`\n\n.\n\nThis is the real lesson, and it is why I did not paper over it with a better search. **MCP got your notes to the model in twenty minutes. Deciding which notes are the right ones is the actual work,** and it is the same search problem it always was. The protocol does not help you with it and was never going to.\n\nThere is a cheap fix that costs nothing, though, and it is the most MCP-shaped part of this whole post: I told the model about the limitation. Read the docstring on `search_notes`\n\nagain. It says there is no ranking, and it says to search more than once with different wording. The model reads that and compensates by trying `ufw`\n\nafter `firewall`\n\ncomes back weak. You cannot fix bad search with a comment, but you can stop the model from trusting it.\n\n`print()`\n\nin a stdio server, and do not trust your own testing on this one.`flush=True`\n\n, or print enough to fill the buffer, or just run long enough, and it corrupts. Mine failed exactly as advertised the moment I flushed: `Failed to parse JSONRPC message from server ... input_value='DEBUG: searching for fail2ban'`\n\n. A footgun that works in testing and detonates in week three is worse than one that fails immediately. Send diagnostics to stderr with `file=sys.stderr`\n\n, which the client ignores and you can still read.`claude`\n\n, not where the server lives. Use `uv run --directory /abs/path server.py`\n\n.`--scope user`\n\nfor anything you want everywhere.`✔ Connected`\n\ndoes not mean the model can call it.`claude -p ...`\n\n), where there is nobody to approve anything and the call is simply refused. Pre-allow them with `--allowedTools \"mcp__notes__search_notes,mcp__notes__get_note\"`\n\n.`uv run --directory /abs/path server.py`\n\ndoes not start on its own, it will not start under Claude Code either, and the error is much easier to read in your terminal.| Task | Command |\n|---|---|\n| New project | `uv init . && uv add \"mcp[cli]\"` |\n| Server object | `mcp = FastMCP(\"notes\")` |\n| Define a tool |\n`@mcp.tool()` above a typed function |\n| Run over stdio | `mcp.run(transport=\"stdio\")` |\n| Log safely | `print(msg, file=sys.stderr)` |\n| Register it | `claude mcp add notes -- uv run --directory /abs/path server.py` |\n| Register everywhere | `claude mcp add --scope user notes -- ...` |\n| List servers | `claude mcp list` |\n| Inspect one | `claude mcp get notes` |\n| Manage in session | `/mcp` |\n| Pre-allow tools | `claude -p \"...\" --allowedTools \"mcp__notes__search_notes\"` |\n| Remove it | `claude mcp remove notes` |", "url": "https://wpnews.pro/news/build-your-first-mcp-server-in-python-give-claude-your-own-notes", "canonical_source": "https://dev.to/peculiarengineer/build-your-first-mcp-server-in-python-give-claude-your-own-notes-lah", "published_at": "2026-07-15 02:40:18+00:00", "updated_at": "2026-07-15 03:29:13.415002+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-tools", "developer-tools", "ai-agents"], "entities": ["Claude", "Model Context Protocol", "Python", "FastMCP", "uv"], "alternates": {"html": "https://wpnews.pro/news/build-your-first-mcp-server-in-python-give-claude-your-own-notes", "markdown": "https://wpnews.pro/news/build-your-first-mcp-server-in-python-give-claude-your-own-notes.md", "text": "https://wpnews.pro/news/build-your-first-mcp-server-in-python-give-claude-your-own-notes.txt", "jsonld": "https://wpnews.pro/news/build-your-first-mcp-server-in-python-give-claude-your-own-notes.jsonld"}}