Portfolio MCP server
This site — portfolio data any MCP client can read.
The site you are reading. Portfolio content is a typed data layer, exposed over Model Context Protocol so an agent can query it directly — through read-only tools with schema validation and an access-control boundary.
Overview
Most portfolios are documents. This one also has an API: a Model Context Protocol endpoint at /api/mcp that any MCP client can connect to and query.
The interesting engineering is the boundary. Each tool declares a Zod input schema, an access level and a handler, so a model can read portfolio data without being trusted with unrestricted access to it.
Pages read the data layer directly and render statically. The MCP endpoint is the only dynamic route, and the site ships no chat interface — the tools are the product, not a conversation about them.
Problem
Claims about tool design, schema validation and access boundaries are exactly the claims a reader cannot verify from prose.
A portfolio is also increasingly read by software, and a marketing page gives an agent noise rather than structure.
Solution
- Model the portfolio as a domain: typed records for projects, experience, skills and engineering decisions, validated at module load.
- Expose that domain through one tool registry served over JSON-RPC, with JSON Schema generated from the same Zod schemas.
- Put every enforcement concern between the caller and the data: validation, access-level authorization, structured results, and telemetry on every call.
Architecture
Clients
Static pagesMCP clientTransport
/api/mcp (JSON-RPC 2.0)Boundary
Zod validationAuthorizationTelemetryDomain
Tool registryQueriesTyped data modules
Components and their edges
→ App Router (RSC) (HTML)
Claude Code, or any MCP host
→ /api/mcp (tools/call)
Static pages, near-zero client JS
→ Data layer
JSON-RPC 2.0
→ Tool registry
Typed, read-only
→ Data layer (validated read)
Validated modules
AI architecture
Caller
Any MCP clientTyped tools
Zod input schemasJSON Schema generatedValidation
Parse or rejectAuthorization
Access level per toolData layer
Query functions
Agent capabilities
get_profile
Headline profile, languages and education.
search_projects
Rank projects by query, theme or stack.
get_project
Full record for one project.
get_architecture
Architecture and AI-architecture graphs for a project.
compare_projects
Two projects across stack, themes and documentation depth.
search_experience
Roles matching a query.
get_experience
The full professional timeline.
get_skills
Skill groups with proficiency banding.
search_engineering_decisions
Decision records by query or scope.
get_engineering_decision
One full decision record.
get_github_activity
Live public GitHub activity.
search_articles
Published writing. Currently returns an empty set.
Data flow
- 01An MCP client POSTs a JSON-RPC request to /api/mcp.
- 02tools/list returns every public tool with JSON Schema generated from its Zod schema.
- 03On tools/call the named tool is resolved — an unknown name is refused before anything else happens.
- 04Arguments are parsed against the tool's Zod schema; a malformed call is rejected before any handler runs.
- 05The authorizer checks the tool's access level against the caller's grants. Owner-level tools are refused for MCP callers.
- 06The handler calls the data layer and returns a typed result, recorded with latency and outcome.
Engineering decisions
A typed tool registry between any model and the data
One registry of named tools. Each declares a description, a Zod input schema, an access level and a handler. Transports adapt to the registry; the registry knows nothing about transports.
Hand-roll the MCP JSON-RPC subset instead of adding the SDK
Implement the JSON-RPC 2.0 subset directly in a route handler, generating JSON Schema for tools/list from the registry's Zod schemas.
Server Components by default, client islands by exception
Every route is a Server Component and statically prerendered. Interactivity is isolated to a named client island; the MCP route handler is the only dynamic endpoint.
Typed modules as the content store, not a database
TypeScript modules parsed through Zod at module load, read by a thin query layer.
Theme with CSS custom properties, not dark: variants
One token set on :root, redefined under prefers-color-scheme for untouched visitors and under [data-theme] for an explicit choice. Components reference semantic tokens only.
Challenges
Generating JSON Schema without a second source of truth
MCP's tools/list needs JSON Schema; the tools are defined in Zod. Generating the schema from the Zod definition rather than hand-writing it means the wire contract cannot drift from the validation that actually runs.
Stateless MCP on a static-first deployment
The MCP SDK assumes stdio and long-lived sessions. This site is statically prerendered with one dynamic route, so the JSON-RPC subset is implemented directly in a route handler — three methods of a published spec, less code than the adapter would have been.
Keeping honesty structural rather than editorial
Undocumented sections are data — a todos array on each project record — so the page, the tool output and any agent reading it all report the same gap. There is no way to look complete by omission.
Testing
- Vitest unit suites over the tool registry: every tool validates its input schema, rejects malformed input, and returns a schema-conformant result.
- Authorization tests assert that owner-level tools refuse a public caller, and that validation runs before authorization.
- A data-integrity suite parses every content module through its Zod schema and checks references between records, so a malformed record fails the build rather than a page.
Security
- Least privilege: every tool is read-only and declares an access level; there is no write tool and no query language.
- Schema validation at the boundary — caller arguments are parsed, never trusted.
- Owner-level tools exist and are enforced: get_agent_telemetry refuses MCP callers, and that refusal is covered by a test.
- Structured, typed failures rather than exceptions, so a calling model can recover instead of seeing a 500.
- Portfolio content is data, not instruction: results are returned as structured JSON, never as directives.
Lessons learned
- Tool design is API design. Once tools have schemas and access levels, most of what gets called 'AI safety' at this scale is ordinary backend discipline.
- Generating the wire schema from the validation schema removes an entire category of bug that would otherwise only appear in someone else's client.
- Making undocumented sections a data structure rather than an editorial habit is what keeps the site honest as it grows.