portfolio-mcp

The agent-native layer of saagarpatel.dev: a Model
Context Protocol server that lets any AI agent query Saagar's writing, projects,
and benchmark results directly, instead of scraping HTML.
Read-only. Stateless. Public. No auth, no tracking, no database, no runtime egress.
How it fits
The website stays a pure static site. This server is a sibling, not a backend
bolted onto it:
- Layer 0 (in the
portfolio-index repo): the build emits a static machine
corpus — corpus-index.json, per-document corpus/<id>.json, and
.well-known/mcp.json — served alongside the HTML. Already public.
- Layer 1 (this repo,
src/index.ts): a stateless Cloudflare Worker that bakes
the Layer 0 corpus into its bundle and serves it over MCP (streamable HTTP, the
WebStandardStreamableHTTPServerTransport). Zero runtime fetches.
- Layer 2 (this repo,
src/stdio.ts): the same server over stdio, for running
locally via npx saagar-portfolio-mcp. Identical tool/resource/prompt surface.
The shared core (src/server.ts) is transport-agnostic; both layers wrap it.
Tool surface
All read-only (readOnlyHint: true). No tool takes a URL or filesystem path (no
SSRF / exfil surface).
search | BM25 over the whole corpus; optional section filter, limit |
get_document | Full Markdown of one document by id |
list_corpus | The table of contents; optional type filter |
get_profile | The "who is this" card (about / now / uses) |
list_projects | Curated public-safe projects + anonymized aggregates |
get_operant_results | Public, sanitized OPERANT calibration results (per-model OCS) |
Documents are also exposed as Resources (portfolio://essays/{slug}, book/{slug},
notes/{slug}, portfolio://profile), and there are two Prompts:
introduce_saagar and summarize_writing_on (grounded in a live search).
Retrieval
BM25 over a baked index (no embeddings in v1 — the corpus is ~50 small docs and the
calling LLM supplies the semantics). Titles are boosted. Embeddings are a measured
Phase 3 upgrade, added only if retrieval quality proves insufficient.
Layout
src/
types.ts corpus + projects + operant shapes
bm25.ts dependency-free BM25 + snippet (pure)
tools.ts createTools(corpus) -> the 6 tools (pure, injectable)
corpus.ts loads the baked corpus + accessors
corpus.generated.ts AUTO-GENERATED by build:corpus
server.ts buildServer(): shared MCP core (tools + resources + prompts)
index.ts Cloudflare Worker transport (streamable HTTP)
stdio.ts Layer 2 stdio transport (the npx CLI)
scripts/
build-corpus.mjs bakes Layer 0 (+ OPERANT) into corpus.generated.ts
probe-mcp.mjs probes an MCP HTTP endpoint: initialize, tools/list, search, OPERANT
smoke-mcp.sh boots wrangler dev, drives the MCP protocol under workerd
audit-mcp.sh connected MCPAudit scan of this server (dogfood)
test/ vitest: bm25, tools, full-protocol server tests
Develop
npm install
npm run build:corpus
npm run typecheck
npm test
npm run dev
bash scripts/smoke-mcp.sh
npm run probe:mcp
Inspect either transport with the MCP inspector:
npx @modelcontextprotocol/inspector http://localhost:8787/mcp
npx @modelcontextprotocol/inspector node dist/stdio.js
Deploy (Layer 1)
npm run build:corpus && npm run deploy
npm run probe:mcp
Operator-gated (needs Cloudflare auth). v1 still deploys to the default
portfolio-mcp.<account>.workers.dev URL, and npm run probe:mcp uses that stable
Worker URL by default. Public discovery now advertises the verified custom endpoint
https://mcp.saagarpatel.dev/mcp; after any deploy, verify both the Worker and the
website manifest/readback path before changing .well-known/mcp.json.
wrangler.jsonc pins workers_dev: true so the public Worker URL stays live during any
future custom-domain experiments; do not remove it unless the website manifest has already
moved to a verified replacement endpoint.
Publish (Layer 2)
npm run build:corpus && npm run build:cli
npm login && npm publish
Once published, anyone can run it locally with npx saagar-portfolio-mcp (no install).
Sign the manifest (optional trust signal)
Ed25519-sign .well-known/mcp.json so an agent or registry can verify it authentically
comes from Saagar. Zero dependencies (Node built-in crypto):
node scripts/sign-manifest.mjs gen-key
node scripts/sign-manifest.mjs sign
node scripts/sign-manifest.mjs verify
Defaults target the sibling portfolio-index manifest (override with --manifest=/--key=/--pub=/--sig=).
Commit the .sig + mcp-ed25519.pub (never the private key) into portfolio-index next to the manifest, then
redeploy the site. Re-run sign whenever the manifest changes (it signs the exact served bytes).
Audit posture
Designed to pass MCPAudit / mcp-trust (Saagar's own tools): only the inbound MCP
transport, no shell_execution / file_access / destructive / exfiltration, and no
caller-controlled egress (the corpus is baked). All tools are annotated read-only with
plain, non-injectable descriptions. bash scripts/audit-mcp.sh runs a connected scan.
Dogfooding this server surfaced a substring-matching false-positive bug in MCPAudit (it
matched port inside portfolio://); that fix lives in the MCPAudit repo and cut this
server's findings 62 → 14. The genuine tool surface scans clean (high_risk_servers: 0).
Status
- Built + verified: Layers 0–2. Shared core + 6 tools + Resources + 2 prompts +
get_operant_results. typecheck clean; 26 tests pass (incl. full MCP protocol via the
fetch handler); wrangler dev workerd smoke green; live Worker probe green; public
discovery advertises mcp.saagarpatel.dev with a valid Ed25519-signed manifest.
- Gated / next: publish the stdio package (
npm publish, after removing
"private": true by explicit operator approval only), glama.ai registry listing, and
continued signed-manifest readback checks after website manifest changes.