I Turned My Portfolio Into an MCP Server
Somebody is going to ask an AI about you before they ask you about you.
That’s not a prediction, it’s just Tuesday now. And when it happens, the agent scrapes whatever HTML it can reach, guesses at your structure, and hopes your <div> soup means what it looks like it means.
My portfolio is a website. But underneath, it’s really a pile of answers to a small set of predictable questions. What has he built? What does he do for clients? Is he available? Browsers get those answers as pages. I wanted agents to get them as data.
So I gave my site an MCP server. It lives at /mcp, it took an afternoon, and it found a bug in my own portfolio within about ten minutes.
What MCP actually is
Strip away the branding and the Model Context Protocol is a small, boring, extremely useful idea: a standard way for an AI client to discover what a server can do, and then do it.
It’s JSON-RPC 2.0. That’s it. A client POSTs {"jsonrpc":"2.0","id":1,"method":"tools/list"} and your server replies with a list of tools. Each tool has a name, a description, and a JSON Schema for its arguments. When the model wants one, the client POSTs tools/call and you run it.
The whole protocol you need for a read-only server is four methods:
initialize— version handshake, and you declare what you supporttools/list— here’s what I can dotools/call— do itnotifications/initialized— client saying “ready”, no reply expected
That’s the entire surface. Everything else in the spec is for servers doing more than mine does.
The part that makes it worth your time is the description field. You’re not writing docs for a human who’ll skim them. You’re writing the only thing the model has to decide whether your tool is the right one. It’s prompt engineering wearing an API’s clothes, and it’s most of the actual work.
Why a portfolio is a good first one
Everyone’s first MCP server is a weather API. Don’t build the weather API. Build something where you already own the data and already know when the answer is wrong.
A portfolio is close to perfect for this:
- The data already exists, structured, in your repo
- It’s entirely public, so there’s no auth to design
- Nothing mutates — no writes, no transactions, no way to cause damage
- You are the world’s leading expert on whether the answers are correct
That last one turned out to matter more than I expected.
Skipping the SDK
There’s an official MCP SDK and it’s good. I didn’t use it, for a specific reason worth explaining.
My site is Astro on Vercel. Astro API routes speak the Web standard: a handler takes a Request and returns a Response. The SDK’s HTTP transport expects Node’s older IncomingMessage and ServerResponse objects. Bridging those two is a real adapter with real edge cases — and for a server whose entire job is receive JSON, return JSON, that shim would have been more code than the protocol it was wrapping.
So the endpoint is one file, and the heart of it is a switch:
switch (method) {
case 'initialize':
return ok(id, {
protocolVersion: version,
capabilities: { tools: { listChanged: false } },
serverInfo: SERVER_INFO,
instructions: INSTRUCTIONS,
});
case 'tools/list':
return ok(id, { tools: TOOLS });
case 'tools/call':
return runTool(id, params);
case 'notifications/initialized':
return null; // notifications get no response, ever
}
Stateless. No sessions, because there’s nothing to remember between calls. If you’re building something read-only, I’d genuinely suggest reading the spec and writing the twenty lines. You’ll understand what your framework is doing for you, which is worth something the first time it misbehaves.
One detail that’s easy to get wrong: when a tool fails, that’s not a protocol error. A JSON-RPC error means the request itself was malformed — unknown method, bad JSON. If the request was fine and your tool just couldn’t do the job, you return a normal successful result with isError: true and the reason in the content.
The difference matters because of who reads it. A protocol error goes to the client and usually surfaces as a crash. An isError result goes to the model, which can read “no project named that, here are the ones that exist” and simply try again. Get this backwards and every typo becomes a dead end instead of a correction.
The eight tools
get_profile, list_projects, get_project, list_writing, get_writing, get_services, get_current_focus, search_work.
Every one reads the same JSON files and the same content collection that the HTML renders from. Not a copy. Not an export. The same files.
That was the whole point. Two surfaces reading one source can’t drift. The moment I maintain a separate feed for agents, it goes stale, and stale is worse than absent — an agent confidently telling someone about a project I abandoned last year is a worse outcome than it knowing nothing.
The part I didn’t expect
I asked it to list my projects. It said seven.
I have four.
projects.json held the things with live URLs. github.json held the repos I wanted featured. Both were correct. Both were rendering fine on the page — one section for products, one for code, and as a human scrolling past, the repetition read as emphasis rather than error.
An agent doesn’t scroll. It got HillRun twice, AppleVille twice, HP Dump Spots twice, with different descriptions each time and no indication they were the same thing.
Then it got worse. I asked for detail on HillRun and got this:
A fun, interactive game for Himachali people. It has a Mario vibe.
That copy was a year out of date. HillRun now has twelve devta shrines you fill by completing daily challenges, a chai stall that banks your apples between runs, and an HRTC bus you learn to recognise by its horn before you ever see it. My own website was describing it as a Mario clone. I’d read that page a hundred times and never once read it.
Schemas are a forcing function
Here’s the thing I actually took away.
I’d known for a while that my data was scattered — a plan sitting in a notes file about “unifying the content model”, the kind of task that never gets urgent enough to do. Refactoring data with nothing depending on the shape is a chore with no deadline and no test.
Writing tool schemas gave it one. get_project has to return a project. Not “whatever’s in projects.json, plus whatever’s in github.json, deduped by whoever’s reading”. Defining the return type made the incoherence impossible to keep ignoring, because now something failed when it was wrong.
The fix was small — merge on a normalised name, one record per thing, carrying both liveUrl and repoUrl. Twenty lines. It had been sitting there for months, invisible, because HTML is forgiving and schemas aren’t.
If your data model is vague, build an API over it. Not for the API. For the questions it makes you answer.
Try it
It’s live. If you have Claude Code:
claude mcp add --transport http anirudh https://anirudhthakur.work/mcp
Then ask it what I’ve built, or what I do for clients, and it’ll answer from the source instead of guessing at my markup.
If you’d rather just look, open anirudhthakur.work/mcp in a browser — a plain GET returns a readable description of the server and its tools rather than the 405 the protocol would technically permit. Being rude to curious humans seemed like the wrong default.
What I’d tell you
- Build one over data you own. You need to be able to tell instantly when the answer is wrong. That’s the whole value.
- Write the descriptions like prompts. They are prompts. The model has nothing else to go on.
- Read the spec before reaching for the SDK. For read-only servers it’s a few dozen lines, and understanding it is worth more than saving them.
- Let your tools fail out loud.
isErrorwith a helpful message beats a protocol error every time. - Expect it to find something. Mine found a year-old lie about my own game. Yours will find something too.
More coming — I’m working through a set of these, and writing up each one as I go.
Stay curious.
Built with caffeine and mountain air from Himachal Pradesh.