Build your first MCP server in Python: give Claude your own notes 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. 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 incantation, I come here instead of wading through Stack Overflow. The 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. What I actually want is a door. Let the model knock when it wants something, and let it read the notes itself. That 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. This post builds that server. It is about sixty lines of Python. TL;DR uv add "mcp cli " , decorate two functions with @mcp.tool , end the file with mcp.run transport="stdio" , and register it with claude mcp add notes -- uv run --directory /abs/path server.py . Use an absolute path or it will not connect, and never print to stdout, because stdout is the transport. You 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. That 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. Everything 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. Two tools: search notes query finds posts containing a phrase and returns their slugs and titles. get note slug returns 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 returned full post bodies, a three word query would dump 40,000 words into the context window and the model would drown before it started. I am pointing this at my blog because that is what I have. Point NOTES at any folder of markdown and it works identically. That is the whole idea. mkdir mcp-notes && cd mcp-notes uv init . uv add "mcp cli " mcp cli is the official Python SDK. The cli extra brings along the development tooling, which you will want later. Here is the whole thing, server.py : """An MCP server that lets Claude read my blog posts. Two tools: search notes finds posts containing a phrase, get note returns one whole post. Point NOTES at any folder of markdown and it works the same. """ import sys from pathlib import Path from mcp.server.fastmcp import FastMCP NOTES = Path.home / "projects/peculiarengineer/src/content/blog" mcp = FastMCP "notes" def posts - list Path : return sorted NOTES.glob " .md" + sorted NOTES.glob " .mdx" def title text: str, fallback: str - str: Frontmatter title, e.g. title: 'Set up SSH keys for Ubuntu' for line in text.splitlines :15 : if line.startswith "title:" : return line.removeprefix "title:" .strip .strip "'\"" return fallback @mcp.tool def search notes query: str - str: """Search my blog posts for a word or phrase. Matches the literal phrase anywhere in a post, including its frontmatter. Returns up to 5 hits with slug and title. There is no ranking: hits come back in filename order, so a post that merely mentions the phrase can crowd out the post that is about it. Search more than once with different wording. Use get note with a slug to read the full post. """ hits = for path in posts : text = path.read text encoding="utf-8" if query.lower in text.lower : hits.append f"{path.stem}: { title text, path.stem }" if len hits == 5: break if not hits: return f"No posts mention {query r}." return "\n".join hits @mcp.tool def get note slug: str - str: """Return the full text of one blog post, by slug. The slug is the filename without its extension, as returned by search notes. """ for path in posts : if path.stem == slug: return path.read text encoding="utf-8" return f"No post with slug {slug r}. Use search notes to find one." if name == " main ": stdout is the transport. A stray print lands in the middle of the JSON-RPC stream, and block buffering means it may not bite until the buffer fills hours later. Diagnostics go to stderr, always. print f"serving {len posts } posts from {NOTES}", file=sys.stderr mcp.run transport="stdio" That is a complete MCP server. Three parts are doing the work. FastMCP "notes" is the server. The name is what shows up in clients. @mcp.tool is 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. mcp.run transport="stdio" starts the loop that reads JSON off stdin and writes JSON to stdout. Notice 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. One command: claude mcp add notes -- uv run --directory /Users/you/projects/mcp-notes server.py The -- matters. Everything before it belongs to claude mcp add , and everything after it is the literal command Claude Code runs to start your server. Without the separator, claude tries to parse your command's flags as its own. The --directory matters 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 uv run server.py , started Claude in my blog repo, and got this: notes: uv run server.py - ✘ Failed to connect Of course it failed. There is no server.py in my blog repo. uv run --directory /abs/path server.py pins it, and now it does not matter where you start Claude from. Check it: claude mcp list notes: uv run --directory /Users/you/projects/mcp-notes server.py - ✔ Connected ✔ Connected means Claude Code launched the process, completed the handshake, and got a tool list back. For more detail, claude mcp get notes shows the scope, the exact command, and any error. By 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 , 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. Your options: --scope local the default for this project only, stored in ~/.claude.json . --scope user for every project you work in. This is what you want for a notes server. --scope project writes a .mcp.json in 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: What did I decide about Secure Boot when installing NVIDIA drivers? The 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 and mcp notes get note . /mcp inside a session lists the server and its tools. Then it works. It calls search notes "Secure Boot" , gets a slug back, calls get note on 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. That 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. But 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. That 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. I promised this, and it is more interesting than I expected. Ask for something specific and it is great. search notes "fail2ban" returns exactly the right three posts. Then try a general word: search notes "firewall" create-sudo-user-ubuntu-26-04: Create a Sudo User on Ubuntu 26.04 enable-ssh-on-ubuntu-desktop: Enable SSH on an Ubuntu desktop extend-azure-windows-disk-run-command: Extending C: on locked-down Azure Windows VMs hardening-ubuntu-26-04-desktop: Hardening Ubuntu 26.04 Desktop Resolute Raccoon hardening-ubuntu-26-04-server: Hardening Ubuntu 26.04 Server Resolute Raccoon Every 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. The substring match did not fail. It found the UFW post fine. The problem is that there is no ranking at all: posts returns 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 sorts after c , e , and h . This 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. There 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 again. 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 after firewall comes back weak. You cannot fix bad search with a comment, but you can stop the model from trusting it. print in a stdio server, and do not trust your own testing on this one. flush=True , 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' . 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 , which the client ignores and you can still read. claude , not where the server lives. Use uv run --directory /abs/path server.py . --scope user for anything you want everywhere. ✔ Connected does not mean the model can call it. claude -p ... , 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" . uv run --directory /abs/path server.py does 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 | |---|---| | New project | uv init . && uv add "mcp cli " | | Server object | mcp = FastMCP "notes" | | Define a tool | @mcp.tool above a typed function | | Run over stdio | mcp.run transport="stdio" | | Log safely | print msg, file=sys.stderr | | Register it | claude mcp add notes -- uv run --directory /abs/path server.py | | Register everywhere | claude mcp add --scope user notes -- ... | | List servers | claude mcp list | | Inspect one | claude mcp get notes | | Manage in session | /mcp | | Pre-allow tools | claude -p "..." --allowedTools "mcp notes search notes" | | Remove it | claude mcp remove notes |