Model Context Protocol (MCP) is the standard Anthropic introduced so that an AI assistant like Claude can call out to real tools and real data instead of just generating text. A client, Claude Code, Claude's desktop app, or ChatGPT's custom connectors, talks to an MCP server over HTTP or stdio; the server exposes a small set of named "tools" with typed inputs, and the model decides when to call them.
I self-host my git repositories on Gitea rather than GitHub, which meant none of the off-the-shelf GitHub MCP servers were of any use to me. So I built a small one of my own: gitea-mcp, a TypeScript server that lets Claude browse, read, write, and delete files directly in my Gitea repos.
The server exposes five tools, each a thin wrapper around Gitea's REST API.
list_repos - list repositories owned by a user (or the authenticated account)
list_files - browse directory contents in a repository, at any branch/tag/commit
read_file - read a file's contents (Gitea returns base64; the server decodes it)
write_file - create or update a file, auto-detecting create vs. update
delete_file - delete a file from a repository
ENDBULLET:In practice, this means I can ask Claude to read a config file from a private repo, edit it, and commit the change, without leaving the chat.
Claude (cloud) -> Nginx (reverse proxy, TLS via Certbot) -> Express + MCP Server (127.0.0.1 only) -> Gitea REST API -> Git repositories
The MCP server itself only ever binds to 127.0.0.1; Nginx is the only thing exposed to the internet, terminating TLS and forwarding to the local Node process.
The stack is deliberately small: the official @modelcontextprotocol/sdk for the protocol plumbing, express for HTTP, and zod for validating tool arguments.
Each tool declares a Zod schema and a handler. For example, write_file:
CODE:const WriteFileSchema = z.object({
repo: z.string().describe("Repository in format 'owner/repo'"),
path: z.string().describe("File path in repo"),
content: z.string().describe("File content"),
message: z.string().describe("Commit message"),
branch: z.string().optional(),
sha: z.string().optional(),
}).strict();
server.tool("write_file", "Create or update a file in a repository", WriteFileSchema.shape,
async (args) => {
const { repo, path, content, message, branch, sha } = WriteFileSchema.parse(args);
const { owner, repo: repoName } = parseRepo(repo);
const result = await client.writeFile(owner, repoName, path, content, message, branch, sha);
return { content: [{ type: "text", text: `File written. Commit: ${result.commit}` }] };
}
ENDCODE:);
The Gitea client underneath handles one quirk worth calling out: Gitea's contents API splits create and update into separate HTTP methods (POST to create, PUT to update - the latter requires the file's current sha). GitHub's equivalent API lets a single PUT do both based on whether sha is present. So write_file first checks whether the file already exists (fetching its sha if the caller didn't supply one) and picks POST or PUT accordingly:
CODE:let finalSha = sha;
if (!finalSha) {
try {
const existing = await this.readFile(owner, repo, path);
finalSha = existing.sha;
} catch {
// File doesn't exist yet - will create instead of update
}
}
const response = await this.fetch(endpoint, {
method: finalSha ? "PUT" : "POST",
ENDCODE: body: JSON.stringify({ content: Buffer.from(content).toString("base64"), message, sha: finalSha }),
});
Transport-wise, the server uses the SDK's StreamableHTTPServerTransport in stateless mode (sessionIdGenerator: undefined) - no session state is kept between requests, which keeps the deployment simple and horizontally scalable if it ever needed to be.
The rough sequence, once the code is written:
NUMBER:Install Node.js 22 LTS on the VPS.
Clone the repo to somewhere like /opt/gitea-mcp and run npm install && npm run build.
Create .env.production with the real secrets (below).
Run it as a systemd service so it survives reboots and restarts on crash - a minimal unit just needs ExecStart=node dist/index.js, a WorkingDirectory, and Restart=on-failure.
Put Nginx in front as a reverse proxy to 127.0.0.1:3000, and enable TLS with Certbot.
ENDNUMBER:Point a DNS record (I use Cloudflare) at the VPS for the public hostname.
CODE:GITEA_URL=https://git.example.com
GITEA_TOKEN=<Gitea personal access token, repo read+write scope>
MCP_AUTH_TOKEN=<random bearer token for Claude Code>
ENDCODE:PORT=3000
One deployment detail that cost some debugging time: Nginx sits in front of the app and sets X-Forwarded-For, but without telling Express to trust that proxy, the SDK's built-in rate limiter (express-rate-limit, used internally by the OAuth router - more on that below) throws on every request and returns a 500. The fix is one line:
CODE:app.set("trust proxy", 1);
CODE:# Health check - no auth required
curl https://mcp.example.com/healthz
# Should fail without a token
curl https://mcp.example.com/mcp
# Register it with Claude Code
claude mcp add --transport http gitea-mcp \
https://mcp.example.com/mcp \
ENDCODE: --header "Authorization: Bearer YOUR_TOKEN"
Claude Code is happy with a static bearer token. ChatGPT's custom MCP connectors are not - they require a full OAuth 2.1 flow. Standing up a complete authorization server just for this felt like overkill, since Gitea already runs its own OAuth2 provider (/login/oauth/authorize, /login/oauth/access_token, /login/oauth/userinfo).
So instead, oauth.ts uses the MCP SDK's ProxyOAuthServerProvider to proxy the whole exchange straight through to Gitea. The one wrinkle: Gitea has no dynamic client-registration endpoint, so there's exactly one statically pre-registered OAuth client, a Gitea "OAuth2 Application" created once, by hand, for ChatGPT's specific redirect URI.
The important thing this layer does not do: it only authenticates who is allowed to call the server (i.e., confirms the caller is really me, via Gitea's own login/consent screen). Every tool call still runs against the single server-side GITEA_TOKEN regardless of which auth path was used to get in.
This is opt-in - four environment variables (MCP_PUBLIC_URL, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI) either are all set together or all left unset. Leave them unset and the server behaves exactly as before: Claude Code's bearer token is the only way in.
A few choices worth being explicit about:
BULLET:The Gitea personal access token never leaves the server - clients only ever hold a bearer token or an OAuth token, never the underlying PAT.
The MCP server binds to 127.0.0.1 only; Nginx is the sole internet-facing surface.
The OAuth path and the static-bearer-token path are independent - disabling one doesn't affect the other.
ENDBULLET:The transport is stateless, so there's no session store to leak or expire incorrectly.
Writing an MCP server is a small amount of code - five tools, a REST client, some auth - but the deployment details (proxy trust, TLS, the create/update split in Gitea's API, ChatGPT's OAuth requirement) are where the actual time went. If you're self-hosting Gitea and want Claude to work in it directly, this is a weekend-sized project, not a multi-week one.