building an MCP server to manage my portfolio
Introduction
I built an MCP server that lets me create, edit, and delete blog posts and projects on this portfolio through an API. It's deployed on Vercel so it runs 24/7. Every change commits directly to GitHub through the GitHub Contents API.
Instead of manually editing MDX files and pushing to git, I can manage everything through a REST API or through Claude using MCP tools.
Architecture
The server has two parts:
Local MCP Server that runs on stdio for Claude Desktop
Vercel API as a serverless function that talks to GitHub directly
Since Vercel serverless functions have no persistent filesystem, the handler uses the GitHub Contents API to read and write files directly in the repo.
How It Works
Reading Posts
Posts are MDX files with YAML frontmatter. The API fetches them from GitHub, base64 decodes the content, and parses the frontmatter:
async function getPost(slug: string) {
const fileData = await ghFetch(
`/contents/${POSTS_DIR}/${slug}.mdx?ref=${GITHUB_BRANCH}`
);
const raw = b64decode(fileData.content);
const { frontmatter, content } = parseFrontmatter(raw);
return { slug, frontmatter, content, sha: fileData.sha };
}The sha is important. GitHub requires it when you want to update or delete a file so it knows you're modifying the correct version.
Creating Posts
Creating a post builds the frontmatter string, base64 encodes the full file, and PUTs it to the GitHub API:
async function createPost(
frontmatter: Record<string, any>,
content: string
) {
const slug = titleToSlug(frontmatter.title);
const fm = buildFrontmatter(frontmatter);
const fileContent = `${fm}\n\n${content}`;
await ghFetch(`/contents/${POSTS_DIR}/${slug}.mdx`, {
method: 'PUT',
body: JSON.stringify({
message: `Create post: ${frontmatter.title}`,
content: b64encode(fileContent),
branch: GITHUB_BRANCH,
}),
});
return { slug, frontmatter, content };
}Each API call creates a commit on GitHub automatically. No need for git add, git commit, git push.
Parsing Projects
The projects file (projectsData.js) is a JS module, not JSON. Parsing it required stripping comment lines without destroying // inside URLs like https://:
// only strip lines where // is the FIRST non-whitespace content
const stripped = raw.replace(/^\s*\/\/.*$/gm, '');Using /\/\/.*$/gm would have turned https://github.com/... into just https:.
Authentication
Every request except /health requires a Bearer token:
function validateAuth(req: VercelRequest): boolean {
if (!API_KEY) return true;
const token = req.headers.authorization?.replace('Bearer ', '');
return token === API_KEY;
}curl -H "Authorization: Bearer YOUR_KEY" \
https://portfolio-mcp.vercel.app/api/postsAPI Endpoints
| Method | Path | Description |
| GET | /health | Health check |
| GET | /api/posts | List all posts |
| GET | /api/posts/:slug | Get post by slug |
| POST | /api/posts | Create post |
| PUT | /api/posts/:slug | Update post |
| DELETE | /api/posts/:slug | Delete post |
| GET | /api/projects | List projects |
| POST | /api/projects | Create project |
| PUT | /api/projects/:title | Update project |
| DELETE | /api/projects/:title | Delete project |
Stack
TypeScript for the MCP server and API handler
Vercel Serverless Functions for always-on deployment
GitHub Contents API for reading/writing files directly
MCP SDK for Claude Desktop integration
Express for the local REST API