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.<my-domain>.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.