ai engineering · september 2026

Your AI Assistant Can
Query My Portfolio

A public, read-only MCP server at /api/mcp - 8 tools, a stateless hand-rolled JSON-RPC dispatcher, and a spec target chosen for what real clients actually speak, not what's newest.

↓ scroll to read  ·  click a section on the left to jump

COLD OPEN · why a portfolio needs an API for AI

Point your assistant at the source, not a stale search index

The Model Context Protocol is an open, JSON-RPC-based way for an AI assistant to call a tool with a defined schema instead of scraping a page and hoping the parse holds up. Claude, Claude Code, Cursor, and a growing list of other clients speak it. I've written about it and list it among my Generative AI skills, so when I wanted a concrete demo of the protocol, wiring one onto my own site felt like the honest way to show it, rather than a résumé line I couldn't back up.

To be clear about scope: this is a small, public demo I built for my own portfolio. It is not professional MCP client integration work - the résumé doesn't claim any, and this article won't either.

WHAT THIS ARTICLE IS ACTUALLY ABOUT

The 8 tools this server exposes, why it targets an older-but-actually-implemented protocol version, why it's hand-rolled JSON-RPC instead of the official SDK on a serverless route, and how to connect your own client to it.

01 / TOOLS · 8 read-only tools, all built on data this site already has

Eight tools, every one read-only

Every tool declares readOnlyHint: true and is built directly on this repo's existing data - never a second, hand-maintained copy of a fact that lives somewhere else.

get_profile

Headline, location, availability, links

Name, headline, one-liner, location, availability, contact/social links, résumé highlights, and a skills summary.

get_experience

Work history

Roles, companies, periods, and the bullet points for each - my full work history, structured.

list_projects

Featured project summaries

Id, title, client, period, and tech for every featured project - use get_project(id) for the full write-up.

get_project(id)

One project, in full

Full description, tech stack, and links for a single project by id, from list_projects.

list_articles(kind?)

Articles and perspectives

Native long-form articles and personal-essay perspectives, with title, url, date, and description, newest first.

list_resources

The /resources hub

Cheatsheets, the cross-OS lookup, /learn tracks, booking, and anything shipping next.

search_site(query, limit)

Full-text search over the whole site

BM25 search - the same retrieval index and chunk data the chatbot uses - returning titles, urls, and snippets.

get_contact_options

How to actually reach me

Contact form, LinkedIn, and /book call options, each marked bookable now or not yet - no freelance project work offered here either.

02 / SPEC TARGET · the newest spec isn't always the right one

Targeting 2025-06-18, not whatever's newest

This server implements MCP protocol version 2025-06-18: the classic initialize-based lifecycle (initialize with version negotiation, notifications/initialized, ping, tools/list, tools/call), plus minimal resources/list/resources/read stubs since this server registers no MCP resources - its content comes back as tool results instead, so it never declares the resources capability.

Why not the officially "current" spec? modelcontextprotocol.io's own current revision has since moved to 2026-07-28, which replaces the initialize/initialized handshake with a server/discover RPC and per-request _meta protocol versioning - a breaking shape change that, as of writing, no mainstream client this server targets (Claude, Claude Code, Cursor, the MCP Inspector) actually speaks. 2025-06-18's own backward-compatibility section still requires supporting the classic lifecycle, so implementing it is both correct today and forward-compatible. The server also negotiates down to 2025-03-26 or 2024-11-05 for an older client, and rejects JSON-RPC batching outright - removed in 2025-06-18, and never supported here even as a courtesy.

// src/lib/mcp/dispatch.ts
export const LATEST_PROTOCOL_VERSION = '2025-06-18';
export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];

// echo the client's version back if we support it, else our own latest
function negotiateProtocolVersion(requested) {
  return SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : LATEST_PROTOCOL_VERSION;
}
THE POINT TO SIT WITH

"Target the newest spec" is the wrong default when the newest spec isn't what your actual clients implement yet. Target what they speak, and design so moving forward later is a version bump, not a rewrite.

03 / HAND-ROLLED · stateless by construction, not by accident

Why this isn't built on the official SDK

@modelcontextprotocol/sdk's Streamable HTTP server transport is built around a long-lived Node http.IncomingMessage/ServerResponse pair and in-memory per-session transport state. An Astro APIRoute on the Vercel adapter gets a Web-standard Request and returns a Response, invoked fresh per request with no guaranteed process continuity between calls - there's no persistent process to hold a session in.

Rather than bridge Node req/res emulation into that model for a JSON-RPC surface this small, src/pages/api/mcp.ts is a thin HTTP adapter - CORS, rate limiting, body parsing - around dispatchRequest(), a pure function with no Request/Response types at all. It takes a parsed JSON-RPC object and a tool list, and returns a JSON-RPC response. Nothing about it depends on HTTP, a session id, or anything from a previous call.

  • Stateless by construction. Every POST is fully self-contained - no session to create, expire, or lose between cold starts.
  • Trivial to unit test. scripts/test-mcp.ts calls dispatchRequest() directly, with a small fixture tool list - no HTTP server, no mocking a transport.
  • The route stays thin. api/mcp.ts only does what an HTTP boundary should: parse, rate-limit, check CORS, hand off, serialize the result.
// simplified - src/lib/mcp/dispatch.ts
export function dispatchRequest(request, tools) {
  switch (request.method) {
    case 'tools/call': {
      const tool = tools.find(t => t.name === request.params.name);
      const args = tool.validate(request.params.arguments);
      return ok(request.id, tool.run(args.value));
    }
  }
} // no Request/Response type in sight
04 / SEARCH · one retrieval index, two surfaces

search_site reuses the chatbot's own BM25 index

search_site is built on exactly the same pieces as "Ask Anupam's AI": the small, dependency-free Bm25Index class and the same generated chat-index.json chunk file, built once at deploy time from the whole site's content. One retrieval implementation, two front ends - a chat widget for people, a JSON-RPC tool for AI clients.

// tools/call for search_site - request
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
  "params": { "name": "search_site", "arguments": { "query": "kubectl logs" } } }

// result - shape, abbreviated
{ "results": [
  { "title": "kubectl cheatsheet", "url": ".../cheatsheets/kubectl", "snippet": "..." }
  ] }

Results are capped at 8 per call, queries at 300 characters - the same defensive limits every other tool here uses, since this is a public endpoint anyone can call.

05 / SAFETY · public, but bounded

Wide-open CORS, its own rate limit, a small body cap

CORS here is deliberately permissive - Access-Control-Allow-Origin: *, only POST, GET, OPTIONS - because MCP clients connecting to a public server aren't same-origin browser code the way this site's own chat widget is. The chat widget's stricter origin allowlist simply doesn't apply to a tool meant to be called from anywhere.

RATE LIMIT

Its own bucket, not the chatbot's

60 requests per 10 minutes, 2,000 per day per IP - from the same shared rate-limit implementation the chatbot uses, namespaced separately so one caller running hot never eats the other's quota.

BODY CAP

32 KB, checked twice

Checked against the Content-Length header and the actual body length, before JSON parsing ever runs.

NO BATCHING

One request per POST

A top-level JSON array is rejected outright, matching 2025-06-18's removal of JSON-RPC batching - never silently truncated to "just the first one."

No secrets touch this route, nothing is written anywhere, and nothing is kept between requests - the entire attack surface is "answer a read-only question about public content, within a rate limit."

06 / CONNECT · five ways in, one endpoint

Connecting your own client

Full copy-paste setup for Claude's custom connector, Claude Code, Cursor, and VS Code lives at anupam.info/mcp - generated from the same tool list this server actually serves, so it can't drift. Here's the raw JSON-RPC, if you'd rather skip a client and just curl it:

# initialize - simplified from anupam.info/mcp's own snippet
$ curl -s https://www.anupam.info/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'

# list the 8 tools
$ curl -s https://www.anupam.info/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

This was checked against the live endpoint on 2026-09-27: initialize answered with the negotiated protocol version, tools/list returned all 8 tools, and calling search_site with "kubectl logs" returned the kubectl cheatsheet as the top hit - the same retrieval index the chatbot uses, working through the JSON-RPC path instead of the chat UI.

07 / CLOSING · clearly labeled as a take

A small server, honestly scoped

I don't think the interesting part of this project is "look, a tool-calling endpoint." It's narrower than that: an AI assistant a recruiter or a fellow engineer already uses can get my actual facts - sourced, current, structured - instead of whatever a general web search or a stale training cutoff hands it. That's a small, concrete win, not a platform.

It has real, stated limits too - worth naming rather than glossing over. list_resources reflects only the curated /resources hub, not a live walk of every content collection on the site; the cheatsheets and /learn tracks already have their own card pointing there, so this is a thin wrapper, not a second index. And this is read-only by design - it will never write, execute, or expose anything not already public.

OPINION, NOT A SPEC

Targeting the spec version your actual clients speak, instead of the newest one a working group just shipped, felt like the less exciting choice while I was building this. In hindsight it's the only choice that made the demo actually work today instead of working in principle for clients that don't exist yet.

FURTHER READING · if you want to go deeper

Where to go next

$ point your assistant at /api/mcp and ask it about me

Field notes: Anupam Kumar · Backend & Generative AI Engineer. v1.0