Skip to main content
Glama
saagpatel

portfolio-mcp

by saagpatel
README.md
# portfolio-mcp

[![Glama MCP server](https://glama.ai/mcp/servers/saagpatel/portfolio-mcp/badge)](https://glama.ai/mcp/servers/saagpatel/portfolio-mcp)

The agent-native layer of [saagarpatel.dev](https://saagarpatel.dev): a Model
Context Protocol server that lets any AI agent query Saagar's writing, projects,
public-safe repo profiles, 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` — plus public-safe repo profile artifacts when present,
  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).

| Tool | Purpose |
|---|---|
| `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 |
| `list_repo_profiles` | Public-safe repo profile index with freshness and proof counts |
| `get_repo_profile` | One repo answering profile by the `repoId` argument |
| `get_operant_results` | Public, sanitized OPERANT calibration results (per-model OCS) |

Documents are also exposed as **Resources** (`portfolio://essays/{slug}`, `portfolio://book/{slug}`,
`portfolio://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 committed corpus contains 166 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 8 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 (saagar-mcp-kit driver + domain calls)
  audit-mcp.sh        connected MCPAudit scan of this server (dogfood)
test/                 vitest: bm25, tools, full-protocol server tests
```

## Develop

From the repository root, use Node 24 (matching CI). The local smoke harness also
requires Bash, Python 3, curl, and standard Unix process utilities (including pkill).
The committed public-safe
corpus is sufficient for routine verification; no sibling checkout, local
portfolio import, signing key, or Cloudflare credentials are needed.

```sh
npm ci
npm test -- test/bm25.test.ts  # focused injected-corpus retrieval fixtures
npm run typecheck
npm test                     # broader Vitest suite
npm run build:cli             # build local stdio transport
npm run smoke                # disposable local workerd/protocol check
npm run parity               # server.json/package.json version parity
```

No separate lint/format script is defined. See `package.json` and
[CI](.github/workflows/verify.yml) for the authoritative commands. `npm run dev`
starts the local Worker. There is no browser UI here: for MCP response changes,
use synthetic corpus/protocol tests and, when useful, the local inspector below.

`npm run build:corpus` regenerates source from a sibling checkout or an explicit
network URL; it is not required for ordinary fixture verification. `npm run
probe:mcp` targets the live Worker by default, and the connected audit starts a
local Worker and invokes the separately installed `mcp-audit` CLI. Keep those
separate from local gates; deployment and signing remain operator-gated as
described below.

Inspect either transport with the MCP inspector:

```sh
npx @modelcontextprotocol/inspector http://localhost:8787/mcp   # Layer 1 (HTTP)
npx @modelcontextprotocol/inspector node dist/stdio.js          # Layer 2 (stdio, after build:cli)
```

## Deploy (Layer 1)

```sh
npm run build:corpus && npm run deploy   # wrangler deploy
npm run probe:mcp                        # post-deploy live MCP readback
```

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)

```sh
npm run build:corpus && npm run build:cli   # -> dist/stdio.js
npm login && npm publish                     # public package: saagar-portfolio-mcp
```

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 (via `saagar-mcp-kit`'s signing CLI, Node built-in crypto):

```sh
npx mcp-kit-sign gen-key --manifest=../portfolio-index/.well-known/mcp.json  # one-time; private key -> .signing/ (gitignored, NEVER commit)
npm run sign                             # writes <manifest>.sig + publishes mcp-ed25519.pub
npm run sign:verify                      # checks manifest bytes against .sig + public key
```

Override paths 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 + locally verified:** Layers 0–2. Shared core + 8 tools (including
  `get_operant_results`) + Resources + 2 prompts. typecheck clean; test suite passes
  (incl. full MCP protocol via the fetch handler). Live Worker probe and deploy remain operator-gated. Public discovery
  advertises `mcp.saagarpatel.dev` with a valid Ed25519-signed manifest.
- **Gated / next:** publish the stdio package (`npm publish`, by explicit operator approval only;
  `package.json` already has no `private` field), glama.ai registry listing, and
  continued signed-manifest readback checks after website manifest changes.