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.carloacutisteam.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.
Gần đây tôi tự host một Gitea trên VPS riêng để làm "nguồn sự thật" (source of truth) cho toàn bộ ghi chép công việc, dự án, và cả kho truyện ngắn đang viết. Nhưng có Gitea thôi chưa đủ — tôi muốn Claude Code và ChatGPT đều đọc/ghi trực tiếp vào repo đó mà không phải clone thủ công mỗi lần. Giải pháp là tự viết một MCP server (Model Context Protocol) đứng giữa, dịch các thao tác file thành API call tới Gitea.
Server viết bằng Node/TypeScript, dùng transport kiểu Streamable HTTP, host trên VPS dev tại mcp.carloacutisteam.com. Các bước chính:
Tạo Gitea Personal Access Token với quyền đọc/ghi repo
Viết source repo hoayluong/gitea-mcp, scaffold project ngay trên VPS
Deploy: cài Node, chạy bằng systemd, đứng sau Nginx + SSL, có DNS record riêng
Đăng ký với Claude Code qua đường bearer-token — verify chạy được end-to-end
Phần này chạy êm, không có gì bất ngờ.
Claude Code dùng bearer token đơn giản, nhưng ChatGPT (custom connector) bắt buộc OAuth 2.1 với PKCE. Gitea có sẵn OAuth2 provider nên tôi chỉ cần viết một lớp proxy (src/oauth.ts) đứng giữa ChatGPT và Gitea. Nghe thì đơn giản, thực tế đụng liên tiếp ba lỗi:
ERR_ERL_UNEXPECTED_X_FORWARDED_FOR — express-rate-limit bên trong router auth ném lỗi vì Nginx có set header X-Forwarded-For nhưng Express chưa được bật trust proxy. Kết quả: mọi request tới /token và /authorize trả về 500 cho ChatGPT. Sửa bằng một dòng: app.set("trust proxy", 1).
Kết nối "fail ngay lập tức", không thấy redirect sang Gitea — hóa ra do route /mcp thiếu CORS (trong khi router auth mặc định đã có). Thêm CORS + logging từng request thì mới thấy nguyên nhân thật: request /authorize từ ChatGPT thiếu code_challenge (PKCE), bị server từ chối đúng luật, bật ngược lại trước khi kịp chạm tới Gitea.
Hóa ra nguyên nhân của lỗi PKCE là ChatGPT đang "reconnect" lại một phiên connector cũ thay vì tạo phiên mới — xoá connector cũ, thêm lại từ đầu thì request mới có đầy đủ PKCE, /token chạy thành công, và ChatGPT bắt đầu gọi POST /mcp thật.
trust proxy là thứ dễ quên nhất khi đặt Express sau Nginx/reverse proxy — không set thì mọi middleware dựa vào IP (rate limit, v.v.) đều có thể vỡ theo cách khó đoán.
Khi một OAuth flow "chết yểu" không rõ lý do, logging từng request ở tầng /authorize và /token quan trọng hơn đọc code — lỗi thực tế (thiếu PKCE) chỉ hiện ra khi nhìn log.
Nếu một connector từng kết nối rồi tự dưng đứt, thử xóa và thêm lại từ đầu trước khi đi tìm bug — có thể client đang tái sử dụng một phiên OAuth cũ, thiếu tham số của phiên mới.
Kết quả cuối: cả Claude Code lẫn ChatGPT giờ đều đọc/ghi trực tiếp vào Gitea repo qua cùng một MCP server, mỗi bên một kiểu xác thực.