Building an MCP Server for My Lab
27 May 2026I like to make things, and I’ve had a ~/local directory full of projects for as long as I can remember. I have my own ~/local/bin of command-line tools I’ve made over the years, and individual projects live in ~/local/{project}. It’s my personal lab.
Over time, my lab started to look a little bit like a graveyard of ideas. A few were active, but most were unfinished.
Last year, I started to tidy up a bit. I archived old ideas I’m no longer interested in. I made Landice work again; I rebuilt the infrastructure for Faculty (and recently redesigned the website, too); I rebuilt my personal server; I made a proper website for Roost; I built a travel log for a local Scout troop (inspired by Landice); and I built a tool to help me stay on top of school emails called Schoolcase (sort of like TripIt for school and activities).
In my last blog post, I described my reluctance to adopt AI. It was being hyped by a lot of the same people who spent years hyping Web3. But I gave it a chance, and I’ve been pleasantly surprised. It’s gotten better, and although I do wonder if the body of work it’s learning from is getting worse, it’s useful in this moment.
When it comes to new technology, some people like to jump right in. Others take the stairs, which might look something like this:
- Use chat as a reference. Sometimes all you need is a reminder or nudge in the right direction.
- Use chat as a rubber duck. Sometimes you just need to talk through a problem, and the solution becomes apparent while you explain it.
- Use chat as a collaborator. You do the typing; you both do some thinking. You talk through problems. You plan. You ask for pushback and critique.
- At some point, you start to let chat write some code to speed up the process.
Whether you jump right in or take the stairs, once you start building with AI, you’re in the same place. This is when things change. Beyond this point, rather than describing AI as chat, I like to describe it as a robot. It’s more capable, and the way you interact with it becomes multifaceted.
Like many before me, I ran into some familiar problems.
The first problem for me was a direct result of a personal preference. I prefer Claude’s chat interface by a large margin. It uses a lovely serif font and a pleasant color palette. Plus, the responses from Claude Code are clearly geared toward execution, not thinking, making it less suitable as a collaborative partner. Good software has a lot more to do with the thinking.
When using the chat interface, I’d have a choice to make when I arrived at a solution — switch to Claude Code and ask it to help implement the solution, or stay in chat and copy and paste code snippets or full files. (A third choice would be to continue writing all of the code myself, but I wanted to work alongside my robot.) Regardless of which path I took, I was dissuaded from participating. It felt like an all-or-nothing decision, not the way I wanted to work. If I stayed in chat, the working files would diverge from the real ones the moment I did anything myself. And Claude Code — which at least has access to the same files you do — is designed to do the work for you.
My robot agrees:
The asymmetry is real: I have hands on the codebase, you’re watching the output scroll by. Even when I narrate what I’m doing, the rhythm is “I do, you approve.”
The second problem was a lack of memory. If a conversation with your robot runs long enough, you need to start a new chat, and you get a new robot that doesn’t remember anything. The forgetting isn’t a flaw; it’s statelessness — each conversation starts from nothing. There are lots of solutions to this problem. I started simply, with Markdown files. Before starting a new chat, I’d ask my robot to update a Markdown file with everything from the current session. This approach works, but it has some downsides. Because I was pasting entire files, every update risked data loss. And, as with code, I found myself dissuaded from making updates myself, leaving everything to the robot. Accuracy drifted, because I wouldn’t bother making small corrections.
The common thread is this: when your robot makes a change, it changes a copy. You paste that copy back over the real file by hand, and if you’ve edited that file yourself in the meantime, your edit is gone. The last save wins. It makes editing your own code feel risky. Every change you make becomes a debt you owe the next paste, and if you forget to pay it, your work vanishes. So you stop. You fall into directing your robot to do everything, because it feels safer to work that way, and you end up with a workflow that punishes you for participating in your own project.
There are solutions to these problems, solved by people long before I encountered them. But many of them focus on solving problems exclusively for the robot. For example, a common approach is to put your knowledge in a vector database the model can search. That works, and it’s the same general approach I’m after: fetch what’s relevant before answering (this approach is known as RAG). The difference is what the knowledge is. A vector store is something only my robot can read. I want something I can read, too. I don’t want to be left on the outside of my own project’s knowledge.
So I decided to build an MCP server for my lab.
MCP is an open standard for connecting AI apps to external systems. For developers, it’s a way to make anything you build available as a tool your robot can use.
I had used MCP in Chrome already. Giving Claude access to Chrome — a browser I don't otherwise use — seemed safer than giving it access to the browser I do use, and with developer tools it could inject things into the DOM, which turned out to be a nice way to quickly prototype ideas.
I had also recently used Directus on a client project, and it ships with an MCP server, which turned out to be a nice side benefit for the client.
I wanted to learn more about MCP, and building my own server seemed like a fun way to do so. But there was a more principled reason, too: I wanted the boundaries on what my robot can touch to be decided by code I wrote, not inherited from whichever app I happen to be using.
The path of least resistance was Python. The most-traveled way to build an MCP server is to use a Python library called FastMCP, and every tutorial assumes it. I chose PHP instead — and not out of nostalgia.
I know Python, but I've been building with PHP for a long time, and it fits how I work — rarely in pure designer or developer mode, but usually a blend of the two. PHP’s roots as a templating language make it a perfect fit: you build logic in the same place you build the UI. This is why PHP and I still get along.
Plus, David Soria Parra — a former release manager for PHP and longtime PHP community member — is one of MCP’s co-creators. PHP isn’t an outsider choice in this ecosystem; it’s in the bloodline. There’s also an official PHP SDK for MCP, built in collaboration with the PHP Foundation and Symfony, and that’s what I built on.
The easiest way to get started is with Composer:
composer require mcp/sdk
Once you have the SDK, the server itself is simple — just a couple of includes and some tools mapped to methods (note the placeholders below):
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;
$server = Server::builder()
->setServerInfo('Your Server', '1.0.0')
->addTool([LibraryTools::class, 'methodName'], 'tool_name')
->addTool([LibraryTools::class, 'anotherMethod'], 'another_tool')
->build();
$server->run(new StdioTransport());
This maps a tool called tool_name to a method called methodName (and a tool called another_tool to anotherMethod). They’re often the same name, but they don’t have to be.
The next step is to configure your MCP server in a file called claude_desktop_config.json. (MCP is supported by ChatGPT and others, too, and this is the only step that differs.) The easiest way to find the file is via the Edit Config button in Settings → Developer in the desktop app. If there isn’t one yet, you can create it with that button and then populate it with the following (adjust paths as needed):
{
"mcpServers": {
"lab": {
"command": "/path/to/php",
"args": [
"/path/to/server.php"
]
}
}
}
The last step is to restart Claude desktop. Claude will run your server, and the tools you added with addTool() will be available to your robot. If you want to test your server first, just run it from the command line — but note that no output is what success looks like.
What I’ve built here is a local MCP server. It runs on my machine and talks to Claude over stdio. The SDK also ships an HTTP transport, so you could put a server on the internet instead. The tool code wouldn’t change — just the transport. Hosting it is a different exercise than a normal PHP site, though. A normal site handles a request and exits, but an MCP server needs to stay running, so you'd run it as a process behind a web server like nginx rather than as a typical PHP app.
That's the whole MCP picture. Local or remote, the server is a set of tools and a transport. If that’s what you came for, you’re all set; the rest of this post is about the particular lab I built on top of it.
I wanted my library to be as accessible to me as it is to my robot. Markdown files are the obvious choice, but I wanted to think through how they would be organized.
I decided to lean into the library metaphor. My library is comprised of three concepts:
- A shelf is a directory in the library
- A book is a Markdown file
- A chapter is a section (
##) in a Markdown file
Reading from this library is pretty straightforward. AI loves Markdown, so file_get_contents() is just about all you need:
public function read(?string $project = null, ?string $book = null): string {
$path = $this->resolveBook($project, $book);
return file_get_contents($path);
}
Updating is a little different. I wanted to give my robot the ability to create or update a book, but I decided that if a book has chapters (i.e., if it’s long enough to warrant sections), the robot should only be able to update one chapter at a time. For shorter books (without chapters), it’s allowed to replace the entire book. This addresses one of the problems with pasting entire files: when my robot is updating everything, information can easily get lost.
In the end, I built standard CRUD tools, but I decided to leave out the D (delete). Removing something from the library requires human intervention, because that’s the kind of friction I want. Creating knowledge should be easy; destroying it shouldn’t be.
The library is half of the lab. The other half is the workshop. I wanted to give Claude’s chat interface the same ability to edit code as Claude Code has, with similar boundaries. If you use Claude Code from the command line, it’s bound by where you are. That’s good, but I wanted something more specific to how I work, and I want Claude to only have access to what I’m currently working on through interfaces I scope.
Claude Code’s main editing tool is a string replacement — find an exact block of code, swap it for another — and that’s what I wanted in chat. So the lab has an edit tool that does the same thing: it’s a str_replace with an additional check. If the string is missing or appears more than once, it refuses with an error message that lets Claude know why. It’s the same spirit as updating a single chapter instead of rewriting a whole book taken one step further, and because it patches the file in place rather than pasting a fresh copy over it, an edit I made myself can’t silently disappear.
I start a working session by saying, in plain words, that I want to work on a project. The server records the project and today’s date. While the session is open, the tools can read and write within that project’s root.
The boundary doesn’t rely on a single check. It’s defense in depth. The first layer of defense is filter input. Project, book, and chapter names must match a strict pattern — lowercase letters, numbers, and hyphens only. A name that matches cannot contain a slash or .., so directory traversal is impossible before a path is even assembled. The second layer is to use realpath to confirm the path is inside the project root. That catches anything the first layer might miss, like a symlink pointing somewhere it shouldn’t.
None of this is new. It’s the same stuff we’ve been doing for years applied to a new kind of tool.
There are two other properties I enforce. First, one project at a time, declared in a file I can read. Second, the session expires at the end of the day it was started. This is as much for me as it is for security. Working past midnight requires a deliberate request to reopen the project. That small bit of friction arrives exactly when I might need it most, when it’s time to close the laptop and go to bed.
Once my MCP server was ready, I put it to the test. I opened a new chat — a robot with no memory of any of this — and I said I wanted to work on the lab.
It read the overview. It read the brief, chapter by chapter: what was done, what was in progress, what was next. It read the books. And then it gave me an accurate, prioritized summary of where the project stood, and asked me what I wanted to focus on. A fresh robot, up to speed and ready to work, in seconds.
That was the forgetting solved. And when Claude and I are both working from the same files, editing stops being a risk. I noticed it the first time I fixed something small. My initial instinct was to tell my robot to do it, because that’s how I had learned to keep the working copy updated, but then I remembered I could just fix it myself. There’s no working copy to reconcile, because there’s no copy. I’m a participant again, not a director afraid to touch anything.
The later versions of the server were built using the server itself. The lab is now being built in the lab.
For some people, participation is inefficiency. For me, it’s what I love to do. I make things.
As weekend projects go, this was a pretty good one, and now that I’ve used it on some real work, I can honestly say it’s a better way for me to work with my robot.
My lab used to be a graveyard of ideas. It still has quite a few unfinished projects, but now there’s a library that remembers them (useful with or without a robot) and a workshop with boundaries I built myself.
It isn't finished, but if you're a PHP developer who'd like to try it, let me know.