I recently set up a self-hosted Gitea instance on my own VPS to act as the single source of truth for my project notes, task tracking, and even an ongoing short-story archive. Having Gitea alone wasn't enough, though — I wanted both Claude Code and ChatGPT to read and write directly into that repo without a manual clone/edit/push cycle every time. The fix was to write a small MCP server (Model Context Protocol) that sits in between and translates file operations into Gitea API calls.
The server is Node/TypeScript, using a Streamable HTTP transport, hosted on my dev VPS at mcp.<my-domain>.com. The main steps:
Generate a Gitea Personal Access Token with repo read/write scope
Create the hoayluong/gitea-mcp source repo and scaffold the project directly on the VPS
Deploy: install Node, run it under systemd, put it behind Nginx + SSL, wire up a dedicated DNS record
Register it with Claude Code over the bearer-token path and verify it end-to-end
This part went smoothly — no surprises.
Claude Code just uses a plain bearer token, but ChatGPT's custom connector requires full OAuth 2.1 with PKCE. Gitea already ships an OAuth2 provider, so the plan was to write a thin proxy layer (src/oauth.ts) between ChatGPT and Gitea. Simple in theory — in practice it took three back-to-back bugs to get working:
ERR_ERL_UNEXPECTED_X_FORWARDED_FOR — express-rate-limit inside the auth router threw because Nginx sets an X-Forwarded-For header but Express never had trust proxy enabled. Every request to /token and /authorize came back as a 500 to ChatGPT. One-line fix: app.set("trust proxy", 1).
The connector "failed instantly," with no redirect to Gitea visible — turned out /mcp was missing CORS (the auth router's own routes already had it). Adding CORS plus per-request logging surfaced the real cause: ChatGPT's /authorize request was missing code_challenge (PKCE), correctly rejected by the server, and bounced back before ever reaching Gitea.
The PKCE issue itself was caused by ChatGPT reusing a stale "reconnect" attempt instead of starting fresh. Deleting the connector and re-adding it from scratch produced a request with proper PKCE, /token succeeded, and ChatGPT started making real POST /mcp calls.
trust proxy is the easiest thing to forget when putting Express behind Nginx or any reverse proxy — skip it and anything IP-dependent (rate limiting, etc.) can fail in confusing ways.
When an OAuth flow dies for no obvious reason, per-request logging at /authorize and /token beats reading the code — the actual bug (missing PKCE) only showed up in the raw logs.
If a connector that once worked suddenly drops, try deleting and re-adding it before chasing a "real" bug — the client may be replaying a stale OAuth attempt instead of starting a clean one.
End result: both Claude Code and ChatGPT now read and write into the same Gitea repo through one MCP server, each using its own auth style.