Moving the MCP server into the Git host
Until August, our MCP setup looked like this:
{ "codebahn": { "type": "stdio", "command": "/home/simon/go/bin/codebahn-mcp", "args": [ "--transport", "stdio", "--url", "https://codebahn.net", "--token", "3bacc606dc..." ] }}A third-party binary, spawned as a subprocess, talking to our API over the public internet with a personal access token in a JSON file. Every machine needed the binary installed, the token pasted in, and someone to update both when things changed.
We ran this for three months. It worked. It also had a token in plaintext on disk, a binary to distribute separately, and a network round-trip for every tool call even though the server and the repos were on the same machine.
Why the protocol made this possible
The MCP spec launched in November 2024 with two transports: stdio, where the client spawns a local process and talks JSON-RPC over stdin/stdout, and HTTP with Server-Sent Events, which required a persistent connection. For a Git host, both meant running something extra: a local binary or a long-lived SSE endpoint.
Streamable HTTP replaced SSE in March 2025: one endpoint, regular HTTP POST, no persistent connection. The July 2026 release candidate went further: sessions optional, initialization handshake removed, every request self-contained.
That made the move obvious. A stateless HTTP handler can live inside an existing web server. The MCP server does not need its own process. It can be a route.
The before and after
Now it looks like this:
{ "codebahn": { "type": "http", "url": "https://codebahn.net/mcp" }}Or from the command line:
claude mcp add --transport http codebahn https://codebahn.net/mcpNo binary to install. No token to create. The client discovers auth on its own: it hits /mcp, gets a 401 pointing to our OAuth metadata (RFC 9728), discovers the authorization server (RFC 8414), registers itself as an OAuth app (RFC 7591), and sends the user to the browser to approve. What comes back is a Bearer token managed by the client, not a plaintext string on disk.
Update, September 2026: we replaced RFC 7591 self-registration with Client ID Metadata Documents. Open dynamic registration let anyone register a client anonymously and point it at their own redirect URI. See the MCP docs for the current setup.
The loopback
When a tool call arrives, the MCP handler calls Codebahn’s own REST API over localhost, forwarding the caller’s token:
func newClient(token, userID string) *apiClient { base := strings.TrimSuffix(setting.LocalURL, "/") return &apiClient{ baseURL: base + "/api/v1", token: token, userID: userID, httpClient: sharedHTTPClient, }}A protocol adapter over the REST API. Every permission check, rate limit, and audit log that applies to the API applies to MCP calls for free. And because the handler has no coupling to Forgejo’s internals, upstream syncs never break it. A drift test walks the API router and asserts every MCP tool has a matching route. If an upstream sync renames an endpoint, the test names the broken tool.
The tool definitions live in a shared Go module (codebahn-cli/tools) imported by both the CLI and the embedded server:
type ToolDef struct { Name string // "create_issue" Group string // "issue" CLIName string // "create" Description string Method string // "POST" PathTmpl string // "/repos/{{.Owner}}/{{.Repo}}/issues" Args any // zero-value struct for schema generation}54 tools across repos, issues, pull requests, search, CI, and user info. Same struct generates Cobra subcommands for the CLI and MCP schemas for the server. One definition, two consumers.
What broke on day one
The Go MCP SDK has a DNS rebinding guard that rejects requests where the socket is loopback but the Host header is not. Behind a reverse proxy, that is every request. Fix: DisableLocalhostProtection: true, since auth is via Bearer tokens, not host binding.
Chrome enforces form-action CSP on the full 302 redirect chain. Our OAuth authorize flow POSTs to our server, which redirects to the client’s callback. Chrome blocked it. Fix: replace the 302 with <meta http-equiv="refresh">, which is not subject to form-action. GitHub has used the same workaround since 2015.
Two MCP clients registering with the same name and loopback redirect URI shared one OAuth application. Fix: dedup by (client_name, redirect_uris) with loopback port normalization per RFC 8252.
How it compares
GitHub runs its MCP server on separate Copilot infrastructure at api.githubcopilot.com. MCP traffic goes through a different service than Git operations. GitLab embeds theirs in the Rails app at /api/v4/mcp, the same approach we took. The reasoning is the same: the Git host already has the repos, the permissions, and the CI. A separate service duplicates or proxies all of that.
We publish benchmark results comparing the embedded endpoint against GitHub’s remote server. Reads complete in about 66 milliseconds. An AI coding session spends about 3% of its wall clock waiting on Codebahn versus a third on GitHub.
One fewer binary. One fewer thing to keep running.
